From 0c7757e3abcbb570411935f125995203bed25db7 Mon Sep 17 00:00:00 2001 From: Kolade Fajimi <107228310+koladefaj@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:44:27 +0100 Subject: [PATCH 1/8] 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 --- litellm/llms/vertex_ai/credentials_source.py | 121 +++++++++++++++++ litellm/llms/vertex_ai/vertex_llm_base.py | 28 ++-- .../llms/vertex_ai/test_vertex_llm_base.py | 122 ++++++++++++++++++ tests/test_litellm/test_secret_redaction.py | 28 ++-- 4 files changed, 274 insertions(+), 25 deletions(-) create mode 100644 litellm/llms/vertex_ai/credentials_source.py diff --git a/litellm/llms/vertex_ai/credentials_source.py b/litellm/llms/vertex_ai/credentials_source.py new file mode 100644 index 00000000000..1c931edc11a --- /dev/null +++ b/litellm/llms/vertex_ai/credentials_source.py @@ -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) diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index 1942bc850f1..eaddd3a9b58 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -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: diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py index a4d67606698..832970bd4c4 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py @@ -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 diff --git a/tests/test_litellm/test_secret_redaction.py b/tests/test_litellm/test_secret_redaction.py index 9fa748edec1..5351817291c 100644 --- a/tests/test_litellm/test_secret_redaction.py +++ b/tests/test_litellm/test_secret_redaction.py @@ -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(): From f9bb956ea80c4477bb2f3126c9e18cea4a58cdcc Mon Sep 17 00:00:00 2001 From: Kolade Fajimi <107228310+koladefaj@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:09:05 +0100 Subject: [PATCH 2/8] test(vertex): narrow pytest.raises to the expected message --- .../llms/vertex_ai/test_vertex_llm_base.py | 18 ++++++++---------- tests/test_litellm/test_secret_redaction.py | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py index 832970bd4c4..1edceff1123 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py @@ -2207,12 +2207,11 @@ class TestVertexCredentialsSource: 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: + 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 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): @@ -2221,24 +2220,22 @@ class TestVertexCredentialsSource: unreadable = tmp_path / "vertexai.json" unreadable.mkdir() - with pytest.raises(ValueError) as exc_info: + 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 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: + 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 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): @@ -2247,11 +2244,12 @@ class TestVertexCredentialsSource: '"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA\n-----END PRIVATE KEY-----"}' ) - with pytest.raises(ValueError) as exc_info: + 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") message = str(exc_info.value) - assert "not valid JSON" in message assert "MIIEvQIBADA" not in message assert "BEGIN PRIVATE KEY" not in message @@ -2297,7 +2295,7 @@ class TestVertexCredentialsSource: missing = tmp_path / "vertexai.json" - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="Unable to read the vertex credentials file") as exc_info: VertexBase().load_auth(credentials=str(missing), project_id="p") logged = str(exc_info.value) @@ -2309,7 +2307,7 @@ class TestVertexCredentialsSource: 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: + 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) diff --git a/tests/test_litellm/test_secret_redaction.py b/tests/test_litellm/test_secret_redaction.py index 5351817291c..730a869ddb7 100644 --- a/tests/test_litellm/test_secret_redaction.py +++ b/tests/test_litellm/test_secret_redaction.py @@ -469,7 +469,7 @@ def test_vertex_error_message_no_credential_leak(): ) for failure in failures: - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="vertex") as exc_info: raise_vertex_credentials_failure(failure) message = str(exc_info.value) assert _redact_string(message) == message # nothing to redact From 7da9cc7edfb8b23198238a41a8516c915d739c90 Mon Sep 17 00:00:00 2001 From: Kolade Fajimi <107228310+koladefaj@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:15:34 +0100 Subject: [PATCH 3/8] fix(vertex): log the credentials path instead of returning it to the caller --- litellm/llms/vertex_ai/credentials_source.py | 61 +++++++++------ .../llms/vertex_ai/test_vertex_llm_base.py | 78 ++++++++++--------- tests/test_litellm/test_secret_redaction.py | 6 +- 3 files changed, 85 insertions(+), 60 deletions(-) diff --git a/litellm/llms/vertex_ai/credentials_source.py b/litellm/llms/vertex_ai/credentials_source.py index 1c931edc11a..b59520d0fbe 100644 --- a/litellm/llms/vertex_ai/credentials_source.py +++ b/litellm/llms/vertex_ai/credentials_source.py @@ -13,7 +13,7 @@ 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 +from litellm._logging import verbose_logger @dataclass(frozen=True, slots=True) @@ -47,6 +47,9 @@ VertexCredentialsFailure: TypeAlias = ( VertexCredentialsFileUnreadable | VertexCredentialsFileNotJson | VertexCredentialsInlineNotJson ) VertexCredentialsSource: TypeAlias = VertexCredentialsJson | VertexCredentialsFailure +_VertexCredentialsFile: TypeAlias = ( + VertexCredentialsJson | VertexCredentialsFileUnreadable | VertexCredentialsFileNotJson +) _JSON_OBJECT_ADAPTER: Final = TypeAdapter(dict[str, object]) @@ -62,6 +65,21 @@ def _parse_json_object(raw: str) -> Mapping[str, object] | _NotAJsonObject: 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") + + 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("{") @@ -74,42 +92,39 @@ def load_vertex_credentials_source(credentials: str) -> VertexCredentialsSource: 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) + if not is_inline_credentials_json(credentials): + return _read_json_file(credentials) + + inline: Final = _parse_json_object(credentials) + if not isinstance(inline, _NotAJsonObject): 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) + # 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 as a 500. + """Map a load failure onto the ValueError the auth flow already surfaces to the caller. - 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. + 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. """ match failure: case VertexCredentialsFileUnreadable(path=path, reason=reason): + verbose_logger.error("Vertex: cannot read the credentials file at %s: %s", path, reason) raise ValueError( - f"Unable to read the vertex credentials file at {redact_string(path)}: {reason}. " + 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", path, detail) raise ValueError( - f"The vertex credentials file at {redact_string(path)} is not valid JSON: {detail}. " + 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): diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py index 1edceff1123..d55901e79fe 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py @@ -1,5 +1,6 @@ import asyncio import json +import logging from unittest.mock import MagicMock, call, patch import pytest @@ -2204,39 +2205,45 @@ class TestVertexCredentialsSource: JSONDecodeError blaming the key material. """ - def test_missing_credentials_file_names_the_path_not_the_json(self, tmp_path): + def test_missing_credentials_file_names_the_read_failure(self, tmp_path, caplog): missing = tmp_path / "vertexai.json" - with pytest.raises(ValueError, match="No such file or directory") as exc_info: - VertexBase().load_auth(credentials=str(missing), project_id="p") + 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 str(missing) in message 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): + 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 pytest.raises(ValueError, match="Unable to read the vertex credentials file") as exc_info: - VertexBase().load_auth(credentials=str(unreadable), project_id="p") + 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 str(unreadable) in message assert "not valid JSON" not in message + assert str(unreadable) not in message + assert str(unreadable) in caplog.text - def test_malformed_credentials_file_names_the_file_and_keeps_the_private_key_hint(self, tmp_path): + 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 pytest.raises(ValueError, match="is not valid JSON") as exc_info: - VertexBase().load_auth(credentials=str(malformed), project_id="p") + 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 str(malformed) in message 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 = ( @@ -2249,6 +2256,15 @@ class TestVertexCredentialsSource: ) 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 @@ -2288,28 +2304,20 @@ class TestVertexCredentialsSource: 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 + def test_a_file_whose_name_starts_with_a_brace_is_still_read(self, tmp_path): + """Shape dispatch must not strand a real file that happens to be named like JSON.""" + braced = tmp_path / "{vertex}.json" + braced.write_text(json.dumps({"type": "service_account", "project_id": "from-braced-file"})) + vertex_base = VertexBase() + mock_creds = MagicMock() + mock_creds.project_id = "from-braced-file" - missing = tmp_path / "vertexai.json" + 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(braced), project_id=None) - with pytest.raises(ValueError, match="Unable to read the vertex credentials file") 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, 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 + 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"} diff --git a/tests/test_litellm/test_secret_redaction.py b/tests/test_litellm/test_secret_redaction.py index 730a869ddb7..65bdd799f87 100644 --- a/tests/test_litellm/test_secret_redaction.py +++ b/tests/test_litellm/test_secret_redaction.py @@ -462,9 +462,10 @@ def test_vertex_error_message_no_credential_leak(): raise_vertex_credentials_failure, ) + path = "/etc/litellm/vertexai.json" 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"), + 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)"), ) @@ -472,6 +473,7 @@ def test_vertex_error_message_no_credential_leak(): 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 From 596cc4c0c27d2079aef1ad3b72b9cdaef29e472c Mon Sep 17 00:00:00 2001 From: Kolade Fajimi <107228310+koladefaj@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:20:45 +0100 Subject: [PATCH 4/8] refactor(vertex): log the credential source decision where it is made --- litellm/llms/vertex_ai/credentials_source.py | 9 +++++++-- litellm/llms/vertex_ai/vertex_llm_base.py | 7 ------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/litellm/llms/vertex_ai/credentials_source.py b/litellm/llms/vertex_ai/credentials_source.py index b59520d0fbe..a973d60d21c 100644 --- a/litellm/llms/vertex_ai/credentials_source.py +++ b/litellm/llms/vertex_ai/credentials_source.py @@ -6,6 +6,7 @@ it is decides what a failure means, so the two are told apart by the shape of th 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 @@ -80,7 +81,7 @@ def _read_json_file(path: str) -> _VertexCredentialsFile: return VertexCredentialsJson(parsed) -def is_inline_credentials_json(credentials: str) -> bool: +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("{") @@ -92,7 +93,11 @@ def load_vertex_credentials_source(credentials: str) -> VertexCredentialsSource: 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 not is_inline_credentials_json(credentials): + 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) diff --git a/litellm/llms/vertex_ai/vertex_llm_base.py b/litellm/llms/vertex_ai/vertex_llm_base.py index eaddd3a9b58..e0886993109 100644 --- a/litellm/llms/vertex_ai/vertex_llm_base.py +++ b/litellm/llms/vertex_ai/vertex_llm_base.py @@ -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 @@ -28,7 +27,6 @@ from .common_utils import ( ) from .credentials_source import ( VertexCredentialsJson, - is_inline_credentials_json, load_vertex_credentials_source, raise_vertex_credentials_failure, ) @@ -134,11 +132,6 @@ class VertexBase: if credentials is not None: if isinstance(credentials, str): source: Final = load_vertex_credentials_source(credentials) - verbose_logger.debug( - "Vertex: Loading vertex credentials, is_file_path=%s, current dir %s", - not is_inline_credentials_json(credentials), - os.getcwd(), - ) if not isinstance(source, VertexCredentialsJson): raise_vertex_credentials_failure(source) json_obj: Mapping[str, object] = source.value From c5f4f124f5863405f175b796378c6df676d3cde4 Mon Sep 17 00:00:00 2001 From: Kolade Fajimi <107228310+koladefaj@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:24:32 +0100 Subject: [PATCH 5/8] fix(vertex): handle paths open() rejects with a bare ValueError --- litellm/llms/vertex_ai/credentials_source.py | 3 +++ tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/litellm/llms/vertex_ai/credentials_source.py b/litellm/llms/vertex_ai/credentials_source.py index a973d60d21c..31510a8981e 100644 --- a/litellm/llms/vertex_ai/credentials_source.py +++ b/litellm/llms/vertex_ai/credentials_source.py @@ -74,6 +74,9 @@ def _read_json_file(path: str) -> _VertexCredentialsFile: 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): diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py index d55901e79fe..6ef89fd8163 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py @@ -2304,6 +2304,13 @@ class TestVertexCredentialsSource: assert project_id == "from-inline" assert from_sa.call_args.args[0] == {"type": "service_account", "project_id": "from-inline"} + 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): """Shape dispatch must not strand a real file that happens to be named like JSON.""" braced = tmp_path / "{vertex}.json" From bdc924b251383bc1b35faaa8f4609bb67f691301 Mon Sep 17 00:00:00 2001 From: Kolade Fajimi <107228310+koladefaj@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:38:26 +0100 Subject: [PATCH 6/8] fix(vertex): redact the credentials path before it reaches the log --- litellm/llms/vertex_ai/credentials_source.py | 8 +++++--- .../llms/vertex_ai/test_vertex_llm_base.py | 11 +++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/litellm/llms/vertex_ai/credentials_source.py b/litellm/llms/vertex_ai/credentials_source.py index 31510a8981e..6c86d02ad21 100644 --- a/litellm/llms/vertex_ai/credentials_source.py +++ b/litellm/llms/vertex_ai/credentials_source.py @@ -15,6 +15,7 @@ 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) @@ -120,17 +121,18 @@ def raise_vertex_credentials_failure(failure: VertexCredentialsFailure) -> NoRet 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. + 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", path, 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", path, 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." diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py index 6ef89fd8163..e636b8c2637 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py @@ -2304,6 +2304,17 @@ class TestVertexCredentialsSource: 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: From eeba33fd951d9d0560c7d8a716a0cd3cc7dc79f4 Mon Sep 17 00:00:00 2001 From: Kolade Fajimi <107228310+koladefaj@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:49:21 +0100 Subject: [PATCH 7/8] test(vertex): cover the brace fallback and non-text credentials file --- litellm/llms/vertex_ai/credentials_source.py | 2 +- .../llms/vertex_ai/test_vertex_llm_base.py | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/litellm/llms/vertex_ai/credentials_source.py b/litellm/llms/vertex_ai/credentials_source.py index 6c86d02ad21..dbeeb593afe 100644 --- a/litellm/llms/vertex_ai/credentials_source.py +++ b/litellm/llms/vertex_ai/credentials_source.py @@ -142,5 +142,5 @@ def raise_vertex_credentials_failure(failure: VertexCredentialsFailure) -> NoRet f"The inline `vertex_credentials` value is not valid JSON: {detail}. " "Check for unescaped newlines in private_key." ) - case _: + case _: # pragma: no cover - exhaustiveness guard, unreachable while the union holds assert_never(failure) diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py index e636b8c2637..00368182cf3 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_llm_base.py @@ -2322,10 +2322,15 @@ class TestVertexCredentialsSource: assert "embedded null" in str(exc_info.value) - def test_a_file_whose_name_starts_with_a_brace_is_still_read(self, tmp_path): - """Shape dispatch must not strand a real file that happens to be named like JSON.""" + 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" @@ -2334,8 +2339,17 @@ class TestVertexCredentialsSource: 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(braced), project_id=None) + 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) From 8a48c1d665e11357cadac0a9e824409cbaea0e6c Mon Sep 17 00:00:00 2001 From: Kolade Fajimi <107228310+koladefaj@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:04:10 +0100 Subject: [PATCH 8/8] test(vertex): exclude the unreachable exhaustiveness guard from coverage --- litellm/llms/vertex_ai/credentials_source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/llms/vertex_ai/credentials_source.py b/litellm/llms/vertex_ai/credentials_source.py index dbeeb593afe..4cbec1c5f1c 100644 --- a/litellm/llms/vertex_ai/credentials_source.py +++ b/litellm/llms/vertex_ai/credentials_source.py @@ -143,4 +143,4 @@ def raise_vertex_credentials_failure(failure: VertexCredentialsFailure) -> NoRet "Check for unescaped newlines in private_key." ) case _: # pragma: no cover - exhaustiveness guard, unreachable while the union holds - assert_never(failure) + assert_never(failure) # pragma: no cover