diff --git a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py index 7fd607fc4d0..3d1be882779 100644 --- a/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py +++ b/litellm/proxy/pass_through_endpoints/passthrough_endpoint_router.py @@ -2,6 +2,8 @@ import json from collections.abc import Callable from typing import TYPE_CHECKING, Final +from fastapi import HTTPException + import litellm from litellm._logging import verbose_router_logger from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import ( @@ -37,6 +39,24 @@ def _credential_identity(credentials: VERTEX_CREDENTIALS_TYPES | None) -> str | return credentials +# Network I/O prohibited; get_credentials() is synchronous inside async routes. +# oidc/env_path/ is excluded because it bypasses the oidc/file/ credential-directory allowlist. +_LOCAL_OIDC_PREFIXES: Final = ("oidc/file/", "oidc/env/") + + +def _resolve_oidc_reference(credential: str | None) -> str | None: + """Resolve local ``oidc/...`` credentials on every call to pick up rotated tokens.""" + if credential is None or not credential.startswith(_LOCAL_OIDC_PREFIXES): + return credential + try: + return get_secret_str(credential) + except (OSError, ValueError) as exc: + raise HTTPException( + status_code=401, + detail=f"pass-through credential {credential} could not be resolved: {exc}", + ) from exc + + class PassthroughEndpointRouter: """ Use this class to Get credentials for pass-through endpoints @@ -60,14 +80,14 @@ class PassthroughEndpointRouter: region_name=region_name, ) if deployment_api_key is not None: - return deployment_api_key + return _resolve_oidc_reference(deployment_api_key) verbose_router_logger.debug( "No pass-through deployment credentials found for %s, looking for env variable", custom_llm_provider ) _env_variable_name: Final = self._get_default_env_variable_name_passthrough_endpoint( custom_llm_provider=custom_llm_provider, ) - return get_secret_str(_env_variable_name) + return _resolve_oidc_reference(get_secret_str(_env_variable_name)) def _get_deployment_api_key( self, diff --git a/tests/unit/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py b/tests/unit/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py index 7a272a49853..87bc47f783f 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py +++ b/tests/unit/proxy/pass_through_endpoints/test_passthrough_endpoint_router.py @@ -1,4 +1,5 @@ import pytest +from fastapi import HTTPException import litellm from litellm.litellm_core_utils.credential_accessor import CredentialAccessor @@ -45,10 +46,7 @@ def test_credential_loaded_after_deployment_registration_still_resolves(): CredentialAccessor.upsert_credentials([_credential("cred_openai", "sk-loaded-after-boot")]) - assert ( - passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) - == "sk-loaded-after-boot" - ) + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-loaded-after-boot" def test_credential_rotation_is_reflected_without_deployment_update(): @@ -58,17 +56,11 @@ def test_credential_rotation_is_reflected_without_deployment_update(): ) passthrough_router = _passthrough_router(llm_router) - assert ( - passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) - == "sk-before-rotation" - ) + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-before-rotation" CredentialAccessor.upsert_credentials([_credential("cred_openai", "sk-after-rotation")]) - assert ( - passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) - == "sk-after-rotation" - ) + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-after-rotation" def test_deleted_deployment_stops_serving_its_key(monkeypatch): @@ -80,9 +72,7 @@ def test_deleted_deployment_stops_serving_its_key(monkeypatch): llm_router.set_model_list([]) monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") - assert ( - passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-from-env" - ) + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-from-env" def test_inline_api_key_resolves_without_credential_name(): @@ -91,10 +81,7 @@ def test_inline_api_key_resolves_without_credential_name(): ) passthrough_router = _passthrough_router(llm_router) - assert ( - passthrough_router.get_credentials(custom_llm_provider="anthropic", region_name=None) - == "sk-ant-inline" - ) + assert passthrough_router.get_credentials(custom_llm_provider="anthropic", region_name=None) == "sk-ant-inline" def test_missing_credential_and_no_inline_key_falls_back_to_env(monkeypatch): @@ -104,9 +91,7 @@ def test_missing_credential_and_no_inline_key_falls_back_to_env(monkeypatch): passthrough_router = _passthrough_router(llm_router) monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") - assert ( - passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-from-env" - ) + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-from-env" def test_deployment_for_other_provider_does_not_match(): @@ -147,9 +132,7 @@ def test_first_matching_deployment_wins(): def test_assemblyai_region_matching(): llm_router = litellm.Router( model_list=[ - _flagged_deployment( - "assemblyai/best", api_key="sk-eu", api_base="https://api.eu.assemblyai.com" - ), + _flagged_deployment("assemblyai/best", api_key="sk-eu", api_base="https://api.eu.assemblyai.com"), _flagged_deployment("assemblyai/best", api_key="sk-us", api_base="https://api.assemblyai.com"), ] ) @@ -179,9 +162,7 @@ def test_env_fallback_when_no_router(monkeypatch): passthrough_router = _passthrough_router(None) monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") - assert ( - passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-from-env" - ) + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-from-env" def test_returns_none_when_no_router_and_no_env(): @@ -216,9 +197,7 @@ def test_vertex_deployment_resolves_via_named_credential(): ) llm_router = litellm.Router( model_list=[ - _vertex_deployment( - "gemini-live", "vertex_ai/gemini-live-2.5-flash", litellm_credential_name="cred_gcp" - ) + _vertex_deployment("gemini-live", "vertex_ai/gemini-live-2.5-flash", litellm_credential_name="cred_gcp") ] ) passthrough_router = _passthrough_router(llm_router) @@ -276,9 +255,7 @@ def test_vertex_model_hint_prefers_matching_deployment(): passthrough_router = _passthrough_router(_two_vertex_deployments_router()) by_alias = passthrough_router.get_vertex_credentials_from_router_deployments(model="gemini-live") - by_upstream_id = passthrough_router.get_vertex_credentials_from_router_deployments( - model="gemini-live-2.5-flash" - ) + by_upstream_id = passthrough_router.get_vertex_credentials_from_router_deployments(model="gemini-live-2.5-flash") assert by_alias is not None and by_alias.vertex_project == "proj-live" assert by_upstream_id is not None and by_upstream_id.vertex_project == "proj-live" @@ -388,12 +365,100 @@ def test_vertex_deployment_with_deleted_credential_is_skipped(monkeypatch): ) llm_router = litellm.Router( model_list=[ - _vertex_deployment( - "gemini-live", "vertex_ai/gemini-live-2.5-flash", litellm_credential_name="cred_gone" - ) + _vertex_deployment("gemini-live", "vertex_ai/gemini-live-2.5-flash", litellm_credential_name="cred_gone") ] ) passthrough_router = _passthrough_router(llm_router) monkeypatch.setattr(litellm, "credential_list", []) assert passthrough_router.get_vertex_credentials_from_router_deployments(model=None) is None + + +def _token_file(tmp_path, monkeypatch, value: str): + monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", str(tmp_path)) + token = tmp_path / "token" + token.write_text(value) + return token + + +def test_env_oidc_file_reference_is_read_on_every_call(tmp_path, monkeypatch): + token = _token_file(tmp_path, monkeypatch, "token-one") + monkeypatch.setenv("OPENAI_API_KEY", f"oidc/file/{token}") + passthrough_router = _passthrough_router(None) + + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "token-one" + + token.write_text("token-two") + + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "token-two" + + +def test_deployment_oidc_file_reference_is_resolved(tmp_path, monkeypatch): + token = _token_file(tmp_path, monkeypatch, "deployment-token") + llm_router = litellm.Router(model_list=[_flagged_deployment("openai/gpt-4o", api_key=f"oidc/file/{token}")]) + + assert ( + _passthrough_router(llm_router).get_credentials(custom_llm_provider="openai", region_name=None) + == "deployment-token" + ) + + +def test_oidc_file_reference_outside_allowed_dirs_is_refused(tmp_path, monkeypatch): + allowed = tmp_path / "allowed" + allowed.mkdir() + monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", str(allowed)) + outside = tmp_path / "token" + outside.write_text("not-allowed") + monkeypatch.setenv("OPENAI_API_KEY", f"oidc/file/{outside}") + + with pytest.raises(HTTPException) as excinfo: + _passthrough_router(None).get_credentials(custom_llm_provider="openai", region_name=None) + + assert excinfo.value.status_code == 401 + assert f"oidc/file/{outside}" in str(excinfo.value.detail) + assert "outside the allowed credential directories" in str(excinfo.value.detail) + + +def test_missing_oidc_token_file_is_a_clear_auth_error(tmp_path, monkeypatch): + monkeypatch.setenv("LITELLM_OIDC_ALLOWED_CREDENTIAL_DIRS", str(tmp_path)) + monkeypatch.setenv("OPENAI_API_KEY", f"oidc/file/{tmp_path / 'missing-token'}") + + with pytest.raises(HTTPException) as excinfo: + _passthrough_router(None).get_credentials(custom_llm_provider="openai", region_name=None) + + assert excinfo.value.status_code == 401 + assert f"oidc/file/{tmp_path / 'missing-token'}" in str(excinfo.value.detail) + + +def test_oidc_env_reference_is_resolved(monkeypatch): + monkeypatch.setenv("UPSTREAM_TOKEN", "env-token") + monkeypatch.setenv("OPENAI_API_KEY", "oidc/env/UPSTREAM_TOKEN") + + assert _passthrough_router(None).get_credentials(custom_llm_provider="openai", region_name=None) == "env-token" + + +def test_oidc_env_path_reference_is_not_resolved(tmp_path, monkeypatch): + outside = tmp_path / "token" + outside.write_text("must-not-be-read") + monkeypatch.setenv("UPSTREAM_TOKEN_FILE", str(outside)) + monkeypatch.setenv("OPENAI_API_KEY", "oidc/env_path/UPSTREAM_TOKEN_FILE") + + assert ( + _passthrough_router(None).get_credentials(custom_llm_provider="openai", region_name=None) + == "oidc/env_path/UPSTREAM_TOKEN_FILE" + ) + + +def test_network_backed_oidc_reference_is_not_fetched_inline(monkeypatch): + def fail(*args, **kwargs): + raise AssertionError("network-backed oidc reference was resolved inside the request path") + + monkeypatch.setattr( + "litellm.proxy.pass_through_endpoints.passthrough_endpoint_router.get_secret_str", + lambda name, *a, **k: "oidc/google/https://example.com" if name == "OPENAI_API_KEY" else fail(), + ) + + assert ( + _passthrough_router(None).get_credentials(custom_llm_provider="openai", region_name=None) + == "oidc/google/https://example.com" + )