mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
* fix(secrets): verify provider API request and payload contracts * wip * fix(secrets): unify backend reads and route secret resolution * feat(secrets): bind built-in managers to retained Rust backends * refactor(secrets): centralize catalog dispatch and native binding * test(secrets): split provider integration tests * refactor(secrets): enforce cache and rotation contracts * test(secrets): stub parent packages in failing resolver fixture Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(secrets): pass manager settings through the interop boundary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(secrets): align cloud KMS auth and harden provider reads * ci(rust): raise native wheel size gate to 40 MB for secrets backends Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): treat unset google kms flag as disabled like the old loader Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(secrets): preserve certificate credentials and disabled KMS flags * test(secrets): cover certificate validation and bounded auth retries * test(secrets): cover Python dispatch without the native extension * test(proxy): skip legacy secret manager cases when the optional SDK is missing Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(secrets): port Python parity tests and preserve provider behavior * fix(secrets): store the captured native config without setattr to satisfy the strict lint budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(secrets): preserve missing Azure manager values Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(secrets): pin typed values and recovery failure precedence * refactor(secrets): organize provider internals and behavioral test suites * refactor(secrets): simplify recovery and isolate Python compatibility * fix(secrets): distinguish Azure callback absence from HTTP not found * fix(secrets): preserve Python AWS read results at the bridge * fix(secrets): route public reads through the native catalog bridge * fix(secrets): keep JSON selection outside the bridge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(secrets): preserve provider JSON reads at the bridge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(secrets): preserve Python primary JSON semantics Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(secrets): preserve CyberArk mutation behavior through the native bridge * docs(secrets): record public API replacement gaps * refactor(secrets): share Vault write payload preparation * feat(secrets): route Vault mutations through the native bridge * fix(secrets): preserve typed Vault rotation failures * refactor(secrets): move Python dispatch into bridge * refactor(secrets): move CyberArk Python policy into bridge * refactor(secrets): move Vault Python policy into bridge * test(secrets): assert Vault rotation request paths * fix(secrets): keep bridge JSON interop centralized Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Yujong Lee <yujong@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
441 lines
16 KiB
Python
441 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Awaitable, Generator, Mapping
|
|
from contextlib import contextmanager
|
|
from dataclasses import replace
|
|
from types import MappingProxyType
|
|
from typing import Final, Literal, Protocol, TypeAlias, cast
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import litellm
|
|
from litellm.integrations.custom_secret_manager import CustomSecretManager
|
|
from litellm.llms.base_llm.ocr.transformation import OCRResponse
|
|
from litellm.ocr import main
|
|
from litellm.rust_bridge import settings
|
|
from litellm.rust_bridge.ocr.entrypoints import NATIVE_AOCR, NATIVE_OCR, LiteLLMOcrRequest
|
|
from litellm.types.secret_managers.main import KeyManagementSettings, KeyManagementSystem
|
|
from tests.test_litellm_rust.support.recording_server import RecordingServer, ResponseSpec, recording_service
|
|
from tests.test_litellm_rust.support.requests import OCR_DOCUMENT, OCR_MODEL, OCR_RESPONSE
|
|
|
|
native: Final = pytest.importorskip("litellm.rust_bridge._native")
|
|
|
|
AccessMode: TypeAlias = Literal["read_only", "write_only", "read_and_write"]
|
|
|
|
|
|
class Ocr(Protocol):
|
|
def __call__(self, api_base: str, /) -> Awaitable[OCRResponse]: ...
|
|
|
|
|
|
class _VaultSecrets(CustomSecretManager):
|
|
def __init__(self, failure: BaseException | None = None) -> None:
|
|
super().__init__(secret_manager_name="rust_bridge_ocr_test")
|
|
self.failure: Final = failure
|
|
self.reads: tuple[tuple[str, Mapping[str, object] | None], ...] = ()
|
|
|
|
async def async_read_secret(
|
|
self,
|
|
secret_name: str,
|
|
optional_params: dict[str, object] | None = None,
|
|
timeout: float | httpx.Timeout | None = None,
|
|
) -> str | None:
|
|
raise AssertionError("get_secret reads custom managers synchronously")
|
|
|
|
def sync_read_secret(
|
|
self,
|
|
secret_name: str,
|
|
optional_params: dict[str, object] | None = None,
|
|
timeout: float | httpx.Timeout | None = None,
|
|
) -> str | None:
|
|
self.reads = (*self.reads, (secret_name, optional_params))
|
|
if secret_name != "MISTRAL_API_KEY":
|
|
return None
|
|
if self.failure is not None:
|
|
raise self.failure
|
|
return "vault-key"
|
|
|
|
def key_reads(self) -> tuple[Mapping[str, object] | None, ...]:
|
|
return tuple(params for name, params in self.reads if name == "MISTRAL_API_KEY")
|
|
|
|
|
|
def _native_request(api_base: str) -> LiteLLMOcrRequest:
|
|
return LiteLLMOcrRequest(
|
|
model=OCR_MODEL,
|
|
document=OCR_DOCUMENT,
|
|
api_key=None,
|
|
api_base=api_base,
|
|
timeout=None,
|
|
custom_llm_provider=None,
|
|
extra_headers=None,
|
|
kwargs=MappingProxyType({}),
|
|
)
|
|
|
|
|
|
def _public_kwargs(api_base: str) -> dict[str, object]:
|
|
return {"model": OCR_MODEL, "document": OCR_DOCUMENT, "api_base": api_base}
|
|
|
|
|
|
async def _python_ocr(api_base: str) -> OCRResponse:
|
|
response: Final = main.ocr(model=OCR_MODEL, document=OCR_DOCUMENT, api_base=api_base)
|
|
assert isinstance(response, OCRResponse)
|
|
return response
|
|
|
|
|
|
async def _python_aocr(api_base: str) -> OCRResponse:
|
|
return await main.aocr(model=OCR_MODEL, document=OCR_DOCUMENT, api_base=api_base)
|
|
|
|
|
|
async def _rust_ocr(api_base: str) -> OCRResponse:
|
|
route: Final = NATIVE_OCR.load()
|
|
assert route is not None
|
|
return route(_native_request(api_base), (), _public_kwargs(api_base))
|
|
|
|
|
|
async def _rust_aocr(api_base: str) -> OCRResponse:
|
|
route: Final = NATIVE_AOCR.load()
|
|
assert route is not None
|
|
return await route(_native_request(api_base), (), _public_kwargs(api_base))
|
|
|
|
|
|
_RUST_PATHS: Final = (_rust_ocr, _rust_aocr)
|
|
_RUST_IDS: Final = ("rust-sync", "rust-async")
|
|
|
|
|
|
@pytest.fixture(params=(_python_ocr, _python_aocr, *_RUST_PATHS), ids=("python-sync", "python-async", *_RUST_IDS))
|
|
def ocr(request: pytest.FixtureRequest) -> Ocr:
|
|
return cast(Ocr, request.param)
|
|
|
|
|
|
@pytest.fixture(params=_RUST_PATHS, ids=_RUST_IDS)
|
|
def rust_ocr(request: pytest.FixtureRequest) -> Ocr:
|
|
return cast(Ocr, request.param)
|
|
|
|
|
|
@contextmanager
|
|
def _mistral_service(expected_requests: int = 1) -> Generator[RecordingServer]:
|
|
with recording_service() as server:
|
|
server.default_response = ResponseSpec(body=OCR_RESPONSE)
|
|
server.expected_requests = expected_requests
|
|
yield server
|
|
|
|
|
|
def _configure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
manager: _VaultSecrets,
|
|
key_management: KeyManagementSettings,
|
|
native_secret_manager: bool = True,
|
|
environment_key: str | None = "environment-key",
|
|
) -> None:
|
|
if environment_key is None:
|
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
|
else:
|
|
monkeypatch.setenv("MISTRAL_API_KEY", environment_key)
|
|
monkeypatch.setattr(litellm, "secret_manager_client", manager)
|
|
monkeypatch.setattr(litellm, "_key_management_system", KeyManagementSystem.CUSTOM)
|
|
monkeypatch.setattr(litellm, "_key_management_settings", key_management)
|
|
configured: Final = settings.secret_manager
|
|
monkeypatch.setattr(settings, "secret_manager", lambda: replace(configured(), native=native_secret_manager))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("access_mode", "hosted_keys"),
|
|
(("read_only", None), ("read_and_write", None), ("read_only", ["MISTRAL_API_KEY"])),
|
|
)
|
|
async def test_custom_secret_manager_supplies_the_ocr_key(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr, access_mode: AccessMode, hosted_keys: list[str] | None
|
|
) -> None:
|
|
manager: Final = _VaultSecrets()
|
|
key_management: Final = KeyManagementSettings(access_mode=access_mode, hosted_keys=hosted_keys)
|
|
_configure(monkeypatch, manager=manager, key_management=key_management)
|
|
|
|
with _mistral_service() as server:
|
|
await ocr(server.base_url)
|
|
|
|
assert server.requests[0].headers["authorization"] == "Bearer vault-key"
|
|
assert manager.key_reads(), "the custom manager was never asked for MISTRAL_API_KEY"
|
|
assert all(params == key_management.model_dump() for params in manager.key_reads()), manager.key_reads()
|
|
|
|
|
|
@pytest.mark.parametrize(("access_mode", "hosted_keys"), (("read_only", ["OTHER"]), ("write_only", None)))
|
|
async def test_custom_secret_manager_is_not_read_when_settings_exclude_the_key(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr, access_mode: AccessMode, hosted_keys: list[str] | None
|
|
) -> None:
|
|
manager: Final = _VaultSecrets()
|
|
_configure(
|
|
monkeypatch,
|
|
manager=manager,
|
|
key_management=KeyManagementSettings(access_mode=access_mode, hosted_keys=hosted_keys),
|
|
)
|
|
|
|
with _mistral_service() as server:
|
|
await ocr(server.base_url)
|
|
|
|
assert server.requests[0].headers["authorization"] == "Bearer environment-key"
|
|
assert manager.key_reads() == ()
|
|
|
|
|
|
async def test_custom_secret_manager_exceptions_fall_back_to_the_environment_key(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr
|
|
) -> None:
|
|
_configure(
|
|
monkeypatch,
|
|
manager=_VaultSecrets(ValueError("secret manager failed")),
|
|
key_management=KeyManagementSettings(access_mode="read_only"),
|
|
)
|
|
|
|
with _mistral_service() as server:
|
|
await ocr(server.base_url)
|
|
|
|
assert server.requests[0].headers["authorization"] == "Bearer environment-key"
|
|
|
|
|
|
async def test_custom_secret_manager_exceptions_without_environment_key_raise_missing_key(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr
|
|
) -> None:
|
|
_configure(
|
|
monkeypatch,
|
|
manager=_VaultSecrets(ValueError("secret manager failed")),
|
|
key_management=KeyManagementSettings(access_mode="read_only"),
|
|
environment_key=None,
|
|
)
|
|
|
|
with _mistral_service(expected_requests=0) as server:
|
|
with pytest.raises(litellm.APIConnectionError, match="Missing Mistral API Key"):
|
|
await ocr(server.base_url)
|
|
|
|
|
|
async def test_custom_secret_manager_cancellation_propagates_without_provider_io(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr
|
|
) -> None:
|
|
failure: Final = asyncio.CancelledError("secret manager cancelled")
|
|
_configure(
|
|
monkeypatch, manager=_VaultSecrets(failure), key_management=KeyManagementSettings(access_mode="read_only")
|
|
)
|
|
|
|
with _mistral_service(expected_requests=0) as server:
|
|
with pytest.raises(asyncio.CancelledError) as raised:
|
|
await ocr(server.base_url)
|
|
|
|
assert raised.value is failure
|
|
|
|
|
|
async def test_rust_declines_a_readable_secret_manager_it_cannot_resolve(
|
|
monkeypatch: pytest.MonkeyPatch, rust_ocr: Ocr
|
|
) -> None:
|
|
manager: Final = _VaultSecrets()
|
|
_configure(
|
|
monkeypatch,
|
|
manager=manager,
|
|
key_management=KeyManagementSettings(access_mode="read_only"),
|
|
native_secret_manager=False,
|
|
)
|
|
|
|
with _mistral_service(expected_requests=0) as server:
|
|
with pytest.raises(native.RustBridgeDeclined):
|
|
await rust_ocr(server.base_url)
|
|
|
|
assert manager.key_reads() == ()
|
|
|
|
|
|
async def test_no_secret_client_leaves_dormant_binding_settings_unread(
|
|
monkeypatch: pytest.MonkeyPatch, rust_ocr: Ocr
|
|
) -> None:
|
|
monkeypatch.setenv("MISTRAL_API_KEY", "environment-key")
|
|
monkeypatch.setattr(litellm, "secret_manager_client", None)
|
|
monkeypatch.setattr(litellm, "_key_management_settings", object())
|
|
|
|
with _mistral_service() as server:
|
|
await rust_ocr(server.base_url)
|
|
|
|
assert server.requests[0].headers["authorization"] == "Bearer environment-key"
|
|
|
|
|
|
class _FixedSecrets(CustomSecretManager):
|
|
def __init__(self, value: str) -> None:
|
|
super().__init__(secret_manager_name="rust_bridge_ocr_fixed")
|
|
self.value: Final = value
|
|
|
|
async def async_read_secret(
|
|
self,
|
|
secret_name: str,
|
|
optional_params: dict[str, object] | None = None,
|
|
timeout: float | httpx.Timeout | None = None,
|
|
) -> str | None:
|
|
raise AssertionError("get_secret reads custom managers synchronously")
|
|
|
|
def sync_read_secret(
|
|
self,
|
|
secret_name: str,
|
|
optional_params: dict[str, object] | None = None,
|
|
timeout: float | httpx.Timeout | None = None,
|
|
) -> str | None:
|
|
return self.value if secret_name == "MISTRAL_API_KEY" else None
|
|
|
|
|
|
class _PlainSecretReader:
|
|
def sync_read_secret(
|
|
self,
|
|
secret_name: str,
|
|
optional_params: dict[str, object] | None = None,
|
|
timeout: float | httpx.Timeout | None = None,
|
|
) -> str | None:
|
|
return "vault-key"
|
|
|
|
|
|
class _AzureSecret:
|
|
def __init__(self, value: str | None) -> None:
|
|
self.value: Final = value
|
|
|
|
|
|
def _azure_sdk_client(value: str | None) -> object:
|
|
class SecretClient:
|
|
def get_secret(self, name: str) -> _AzureSecret:
|
|
return _AzureSecret(value if name == "MISTRAL_API_KEY" else None)
|
|
|
|
SecretClient.__module__ = "azure.keyvault.secrets._client"
|
|
return SecretClient()
|
|
|
|
|
|
def _configure_client(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
client: object,
|
|
system: KeyManagementSystem,
|
|
key_management: KeyManagementSettings,
|
|
environment_key: str = "environment-key",
|
|
) -> None:
|
|
monkeypatch.setenv("MISTRAL_API_KEY", environment_key)
|
|
monkeypatch.setattr(litellm, "secret_manager_client", client)
|
|
monkeypatch.setattr(litellm, "_key_management_system", system)
|
|
monkeypatch.setattr(litellm, "_key_management_settings", key_management)
|
|
configured: Final = settings.secret_manager
|
|
monkeypatch.setattr(settings, "secret_manager", lambda: replace(configured(), native=True))
|
|
|
|
|
|
async def _assert_missing_key(ocr: Ocr) -> None:
|
|
with _mistral_service(expected_requests=0) as server:
|
|
with pytest.raises(litellm.APIConnectionError, match="Missing Mistral API Key"):
|
|
await ocr(server.base_url)
|
|
|
|
|
|
@pytest.mark.parametrize("environment_key", ("true", " FALSE ", "True"))
|
|
async def test_boolean_environment_keys_count_as_missing(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr, environment_key: str
|
|
) -> None:
|
|
monkeypatch.setenv("MISTRAL_API_KEY", environment_key)
|
|
monkeypatch.setattr(litellm, "secret_manager_client", None)
|
|
|
|
await _assert_missing_key(ocr)
|
|
|
|
|
|
@pytest.mark.parametrize("manager_key", ("True", "(False)"))
|
|
async def test_boolean_manager_keys_count_as_missing(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr, manager_key: str
|
|
) -> None:
|
|
_configure_client(
|
|
monkeypatch,
|
|
client=_FixedSecrets(manager_key),
|
|
system=KeyManagementSystem.CUSTOM,
|
|
key_management=KeyManagementSettings(access_mode="read_only"),
|
|
)
|
|
|
|
await _assert_missing_key(ocr)
|
|
|
|
|
|
async def test_boolean_environment_fallback_after_a_manager_exception_counts_as_missing(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr
|
|
) -> None:
|
|
_configure(
|
|
monkeypatch,
|
|
manager=_VaultSecrets(ValueError("secret manager failed")),
|
|
key_management=KeyManagementSettings(access_mode="read_only"),
|
|
environment_key="True",
|
|
)
|
|
|
|
await _assert_missing_key(ocr)
|
|
|
|
|
|
async def test_manager_without_the_key_does_not_fall_back_to_the_environment(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr
|
|
) -> None:
|
|
_configure_client(
|
|
monkeypatch,
|
|
client=_azure_sdk_client(None),
|
|
system=KeyManagementSystem.AZURE_KEY_VAULT,
|
|
key_management=KeyManagementSettings(access_mode="read_only"),
|
|
)
|
|
|
|
await _assert_missing_key(ocr)
|
|
|
|
|
|
async def test_rust_hosted_keys_exclude_azure_sdk_clients_too(monkeypatch: pytest.MonkeyPatch, rust_ocr: Ocr) -> None:
|
|
_configure_client(
|
|
monkeypatch,
|
|
client=_azure_sdk_client("vault-key"),
|
|
system=KeyManagementSystem.AZURE_KEY_VAULT,
|
|
key_management=KeyManagementSettings(access_mode="read_only", hosted_keys=["OTHER"]),
|
|
)
|
|
|
|
with _mistral_service() as server:
|
|
await rust_ocr(server.base_url)
|
|
|
|
assert server.requests[0].headers["authorization"] == "Bearer environment-key", (
|
|
"recorded divergence: Python's get_secret_from_manager recognizes Azure SDK clients by type and ignores hosted_keys"
|
|
)
|
|
|
|
|
|
async def test_custom_system_with_a_foreign_client_falls_back_to_the_environment(
|
|
monkeypatch: pytest.MonkeyPatch, ocr: Ocr
|
|
) -> None:
|
|
_configure_client(
|
|
monkeypatch,
|
|
client=_PlainSecretReader(),
|
|
system=KeyManagementSystem.CUSTOM,
|
|
key_management=KeyManagementSettings(access_mode="read_only"),
|
|
)
|
|
|
|
with _mistral_service() as server:
|
|
await ocr(server.base_url)
|
|
|
|
assert server.requests[0].headers["authorization"] == "Bearer environment-key"
|
|
|
|
|
|
async def test_native_backend_supplies_ocr_credentials_without_a_python_reader(
|
|
monkeypatch: pytest.MonkeyPatch, rust_ocr: Ocr
|
|
) -> None:
|
|
from litellm.secret_managers import main as secret_manager_main
|
|
from litellm.secret_managers import secret_manager_handler
|
|
from litellm.secret_managers.aws_secret_manager_v2 import AWSSecretsManagerV2
|
|
|
|
def reject_python_read(
|
|
client: object,
|
|
key_manager: str,
|
|
secret_name: str,
|
|
key_management_settings: KeyManagementSettings | None = None,
|
|
) -> str | None:
|
|
raise AssertionError("Rust must read the native backend directly")
|
|
|
|
monkeypatch.setattr(secret_manager_handler, "get_secret_from_manager", reject_python_read)
|
|
monkeypatch.setattr(secret_manager_main, "get_secret_from_manager", reject_python_read)
|
|
with recording_service() as secrets, _mistral_service(expected_requests=2) as provider:
|
|
secrets.default_response = ResponseSpec(body={"SecretString": "native-key"})
|
|
secrets.expected_requests = None
|
|
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "native-access")
|
|
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "native-secret")
|
|
monkeypatch.setenv("AWS_BEDROCK_RUNTIME_ENDPOINT", secrets.base_url)
|
|
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
|
|
manager: Final = AWSSecretsManagerV2(aws_region_name="us-east-1")
|
|
monkeypatch.setattr(litellm, "secret_manager_client", manager)
|
|
monkeypatch.setattr(litellm, "_key_management_system", KeyManagementSystem.AWS_SECRET_MANAGER)
|
|
monkeypatch.setattr(litellm, "_key_management_settings", KeyManagementSettings(hosted_keys=["MISTRAL_API_KEY"]))
|
|
monkeypatch.setattr(settings, "secret_manager", lambda: settings.SecretManager(readable=True, native=True))
|
|
await rust_ocr(provider.base_url)
|
|
await rust_ocr(provider.base_url)
|
|
|
|
assert len(secrets.requests) == 2, [(request.path, request.body) for request in secrets.requests]
|
|
assert all(request.headers["authorization"] == "Bearer native-key" for request in provider.requests)
|
|
assert all("Credential=native-access/" in request.headers["authorization"] for request in secrets.requests)
|
|
assert native._SecretManagerRuntime.from_client(manager) is getattr(manager, "_litellm_native_secret_manager")
|