diff --git a/.github/workflows/test-rust.yml b/.github/workflows/test-rust.yml index 161353708f0..6f8599daf75 100644 --- a/.github/workflows/test-rust.yml +++ b/.github/workflows/test-rust.yml @@ -130,7 +130,7 @@ jobs: - name: Test secret manager feature combinations run: | cargo test -p litellm-auth-gcp --locked --no-default-features - for features in '' aws google cyberark aws,google aws,google,cyberark; do + for features in '' aws google azure cyberark aws,google aws,azure google,azure aws,google,azure aws,google,cyberark aws,google,azure,cyberark; do cargo test -p litellm-secrets --locked --no-default-features --features "$features" done diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 85d1e1ca8ef..84f49be9187 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -2704,6 +2704,7 @@ dependencies = [ "jsonwebtoken", "litellm-core-utils", "litellm-secrets-aws", + "litellm-secrets-azure", "litellm-secrets-cyberark", "litellm-secrets-google", "litellm-secrets-types", @@ -2739,6 +2740,26 @@ dependencies = [ "wiremock", ] +[[package]] +name = "litellm-secrets-azure" +version = "0.1.0" +dependencies = [ + "litellm-auth-azure", + "litellm-auth-types", + "litellm-core-utils", + "litellm-secrets-types", + "percent-encoding", + "reqwest 0.12.28", + "rstest", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.19", + "tokio", + "veil", + "wiremock", +] + [[package]] name = "litellm-secrets-cyberark" version = "0.1.0" diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index d35df1eafb8..841efe9e753 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -22,6 +22,7 @@ litellm-secrets = { path = "crates/secrets" } litellm-secrets-types = { path = "crates/secrets-types" } litellm-secrets-aws = { path = "crates/secrets-aws" } litellm-secrets-google = { path = "crates/secrets-google" } +litellm-secrets-azure = { path = "crates/secrets-azure" } litellm-secrets-cyberark = { path = "crates/secrets-cyberark" } litellm-http = { path = "crates/http" } litellm-llms = { path = "crates/llms" } diff --git a/litellm-rust/crates/auth-azure/src/lib.rs b/litellm-rust/crates/auth-azure/src/lib.rs index e76227d6aa2..5c7c654b69d 100644 --- a/litellm-rust/crates/auth-azure/src/lib.rs +++ b/litellm-rust/crates/auth-azure/src/lib.rs @@ -4,4 +4,4 @@ mod resolve; mod types; pub use resolve::AzureAuthService; -pub use types::AzureAuthInputs; +pub use types::{AzureAuthInputs, ConfigValue}; diff --git a/litellm-rust/crates/secrets-azure/Cargo.toml b/litellm-rust/crates/secrets-azure/Cargo.toml new file mode 100644 index 00000000000..96db7f235ef --- /dev/null +++ b/litellm-rust/crates/secrets-azure/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "litellm-secrets-azure" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +litellm-auth-azure.workspace = true +litellm-auth-types.workspace = true +litellm-secrets-types.workspace = true +litellm-core-utils.workspace = true +reqwest.workspace = true +serde.workspace = true +thiserror.workspace = true +veil.workspace = true +percent-encoding = "2.3" + +[dev-dependencies] +tokio.workspace = true +wiremock = "0.6.5" +rstest.workspace = true +serde_json.workspace = true +sha2.workspace = true diff --git a/litellm-rust/crates/secrets-azure/src/error.rs b/litellm-rust/crates/secrets-azure/src/error.rs new file mode 100644 index 00000000000..9b20efe4f7c --- /dev/null +++ b/litellm-rust/crates/secrets-azure/src/error.rs @@ -0,0 +1,25 @@ +#[derive(thiserror::Error, veil::Redact)] +pub enum Error { + #[error("{0} environment variable is missing")] + MissingEnvironment(&'static str), + #[error("AZURE_KEY_VAULT_URI is not a valid https vault URL")] + VaultUri, + #[error("Azure Key Vault credentials are not configured")] + MissingCredentials, + #[error(transparent)] + Auth( + #[from] + #[redact] + litellm_auth_types::Error, + ), + #[error("Azure Key Vault request failed")] + Http( + #[source] + #[redact] + reqwest::Error, + ), + #[error("Azure Key Vault returned HTTP {0}")] + Status(u16), + #[error("Azure Key Vault response is missing the secret value")] + MissingValue, +} diff --git a/litellm-rust/crates/secrets-azure/src/key_vault.rs b/litellm-rust/crates/secrets-azure/src/key_vault.rs new file mode 100644 index 00000000000..e12289b83f5 --- /dev/null +++ b/litellm-rust/crates/secrets-azure/src/key_vault.rs @@ -0,0 +1,118 @@ +use std::sync::Arc; + +use litellm_auth_azure::{AzureAuthInputs, AzureAuthService, ConfigValue}; +use litellm_auth_types::{InputSource, Sourced}; +use litellm_core_utils::settings::Lookup; +use litellm_secrets_types::{Secret, SecretValue}; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC}; +use serde::Deserialize; + +use crate::Error; + +const AZURE_KEY_VAULT_URI: &str = "AZURE_KEY_VAULT_URI"; +const API_VERSION: &str = "7.4"; +const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~'); + +#[derive(Clone)] +pub struct AzureKeyVault { + client: reqwest::Client, + vault: reqwest::Url, + auth: Arc, + inputs: Arc, + environment: Arc, +} + +#[derive(Deserialize)] +struct SecretResponse { + value: Option, +} + +impl AzureKeyVault { + pub fn with_client( + client: reqwest::Client, + vault: reqwest::Url, + environment: Arc, + ) -> Result { + if vault.host_str().is_none() { + return Err(Error::VaultUri); + } + let inputs = AzureAuthInputs { + azure_scope: ConfigValue::Value(Sourced::new( + scope_for(&vault), + InputSource::Deployment, + )), + enable_azure_ad_token_refresh: Sourced::new(true, InputSource::Deployment), + ..AzureAuthInputs::default() + }; + Ok(Self { + client, + vault, + auth: Arc::new(AzureAuthService::default()), + inputs: Arc::new(inputs), + environment, + }) + } + + pub fn new(environment: Arc) -> Result { + let value = environment + .get(AZURE_KEY_VAULT_URI) + .ok_or(Error::MissingEnvironment(AZURE_KEY_VAULT_URI))?; + let vault = reqwest::Url::parse(&value).map_err(|_| Error::VaultUri)?; + if vault.scheme() != "https" || vault.host_str().is_none() { + return Err(Error::VaultUri); + } + Self::with_client(reqwest::Client::new(), vault, environment) + } + + pub fn scope(&self) -> &str { + self.inputs + .azure_scope + .as_value() + .map(|value| value.value().as_str()) + .unwrap_or_default() + } + + pub async fn get_secret_from_azure_key_vault( + &self, + name: &str, + ) -> Result, Error> { + let token = self + .auth + .get_azure_ad_token(&self.inputs, &|key| self.environment.get(key)) + .await? + .ok_or(Error::MissingCredentials)?; + let encoded_name = percent_encoding::utf8_percent_encode(name, PATH_SEGMENT); + let url = self + .vault + .join(&format!("secrets/{encoded_name}?api-version={API_VERSION}")) + .map_err(|_| Error::VaultUri)?; + let response = self + .client + .get(url) + .bearer_auth(token.value().secret().expose()) + .send() + .await + .map_err(Error::Http)?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None); + } + if response.status() != reqwest::StatusCode::OK { + return Err(Error::Status(response.status().as_u16())); + } + let payload: SecretResponse = response.json().await.map_err(Error::Http)?; + let value = payload.value.ok_or(Error::MissingValue)?; + Ok(Some(Secret::String(SecretValue::new(value)))) + } +} + +fn scope_for(vault: &reqwest::Url) -> String { + let host = vault.host_str().unwrap_or_default(); + let resource = host + .split_once('.') + .map_or(host, |(_, remainder)| remainder); + format!("https://{resource}/.default") +} diff --git a/litellm-rust/crates/secrets-azure/src/lib.rs b/litellm-rust/crates/secrets-azure/src/lib.rs new file mode 100644 index 00000000000..c0094fc033b --- /dev/null +++ b/litellm-rust/crates/secrets-azure/src/lib.rs @@ -0,0 +1,7 @@ +#![forbid(unsafe_code)] + +mod error; +mod key_vault; + +pub use error::Error; +pub use key_vault::AzureKeyVault; diff --git a/litellm-rust/crates/secrets-azure/tests/fixtures/key_vault_parity.json b/litellm-rust/crates/secrets-azure/tests/fixtures/key_vault_parity.json new file mode 100644 index 00000000000..c4a83cd150a --- /dev/null +++ b/litellm-rust/crates/secrets-azure/tests/fixtures/key_vault_parity.json @@ -0,0 +1,8 @@ +{ + "cases": [ + {"name": "plain_value", "secret_name": "OPENAI-API-KEY", "response": {"status": 200, "body": {"value": "sk-parity-1", "id": "https://example.vault.azure.net/secrets/OPENAI-API-KEY/abc"}}, "expected": {"value": "sk-parity-1"}}, + {"name": "json_value_is_kept_as_string", "secret_name": "JSON-SECRET", "response": {"status": 200, "body": {"value": "{\"api_key\": \"nested\"}", "id": "https://example.vault.azure.net/secrets/JSON-SECRET/abc"}}, "expected": {"value": "{\"api_key\": \"nested\"}"}}, + {"name": "missing_secret", "secret_name": "MISSING", "response": {"status": 404, "body": {"error": {"code": "SecretNotFound", "message": "not found"}}}, "expected": {"missing": true}}, + {"name": "forbidden", "secret_name": "FORBIDDEN", "response": {"status": 403, "body": {"error": {"code": "Forbidden", "message": "denied"}}}, "expected": {"error": true}} + ] +} diff --git a/litellm-rust/crates/secrets-azure/tests/key_vault.rs b/litellm-rust/crates/secrets-azure/tests/key_vault.rs new file mode 100644 index 00000000000..cf9102d0b45 --- /dev/null +++ b/litellm-rust/crates/secrets-azure/tests/key_vault.rs @@ -0,0 +1,222 @@ +use std::sync::Arc; + +use litellm_secrets_azure::{AzureKeyVault, Error}; +use litellm_secrets_types::{Secret, SecretValue}; +use serde::Deserialize; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, path, query_param}, +}; + +fn manager(server: &MockServer) -> AzureKeyVault { + AzureKeyVault::with_client( + reqwest::Client::new(), + server.uri().parse().unwrap(), + Arc::new(|name: &str| (name == "AZURE_AD_TOKEN").then(|| "fake".to_owned())), + ) + .unwrap() +} + +#[tokio::test] +async fn reads_secret_with_bearer_token_and_api_version() { + let server = MockServer::start().await; + Mock::given(path("/secrets/OPENAI-API-KEY")) + .and(query_param("api-version", "7.4")) + .and(header("authorization", "Bearer fake")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"value": "s3cret", "id": "secret-id"})), + ) + .expect(1) + .mount(&server) + .await; + + let secret = manager(&server) + .get_secret_from_azure_key_vault("OPENAI-API-KEY") + .await + .unwrap() + .unwrap(); + + assert_eq!(secret, Secret::String(SecretValue::new("s3cret"))); +} + +#[tokio::test] +async fn percent_encodes_secret_name_path_segment() { + let server = MockServer::start().await; + Mock::given(path("/secrets/name%2Fwith%20spaces")) + .and(query_param("api-version", "7.4")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "value"})), + ) + .expect(1) + .mount(&server) + .await; + + let secret = manager(&server) + .get_secret_from_azure_key_vault("name/with spaces") + .await + .unwrap() + .unwrap(); + + assert_eq!(secret.as_str(), Some("value")); +} + +#[rstest::rstest] +#[case::not_found(404, None)] +#[case::forbidden(403, Some(403))] +#[tokio::test] +async fn handles_statuses(#[case] status: u16, #[case] expected_status: Option) { + let server = MockServer::start().await; + Mock::given(path("/secrets/NAME")) + .respond_with(ResponseTemplate::new(status)) + .expect(1) + .mount(&server) + .await; + + let result = manager(&server) + .get_secret_from_azure_key_vault("NAME") + .await; + + match expected_status { + None => assert_eq!(result.unwrap(), None), + Some(status) => assert!(matches!(result, Err(Error::Status(actual)) if actual == status)), + } +} + +#[tokio::test] +async fn missing_value_is_an_error() { + let server = MockServer::start().await; + Mock::given(path("/secrets/NAME")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(&server) + .await; + + assert!(matches!( + manager(&server) + .get_secret_from_azure_key_vault("NAME") + .await, + Err(Error::MissingValue) + )); +} + +#[test] +fn new_validates_vault_environment() { + assert!(matches!( + AzureKeyVault::new(Arc::new(|_: &str| None)), + Err(Error::MissingEnvironment("AZURE_KEY_VAULT_URI")) + )); + assert!(matches!( + AzureKeyVault::new(Arc::new(|name: &str| { + (name == "AZURE_KEY_VAULT_URI").then(|| "http://vault.example".to_owned()) + })), + Err(Error::VaultUri) + )); + assert!(matches!( + AzureKeyVault::new(Arc::new(|name: &str| { + (name == "AZURE_KEY_VAULT_URI").then(|| "vault.example".to_owned()) + })), + Err(Error::VaultUri) + )); +} + +#[rstest::rstest] +#[case("https://myvault.vault.azure.net", "https://vault.azure.net/.default")] +#[case( + "https://v.vault.usgovcloudapi.net/", + "https://vault.usgovcloudapi.net/.default" +)] +#[case("http://localhost:8080", "https://localhost/.default")] +#[test] +fn derives_scope_from_vault_host(#[case] uri: &str, #[case] expected: &str) { + let manager = AzureKeyVault::with_client( + reqwest::Client::new(), + uri.parse().unwrap(), + Arc::new(|_: &str| None), + ) + .unwrap(); + + assert_eq!(manager.scope(), expected); +} + +#[tokio::test] +async fn missing_credentials_do_not_request_vault() { + let server = MockServer::start().await; + Mock::given(path("/secrets/NAME")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + assert!( + manager_without_credentials(&server) + .get_secret_from_azure_key_vault("NAME") + .await + .is_err() + ); +} + +fn manager_without_credentials(server: &MockServer) -> AzureKeyVault { + AzureKeyVault::with_client( + reqwest::Client::new(), + server.uri().parse().unwrap(), + Arc::new(|name: &str| { + (name == "AZURE_CREDENTIAL").then(|| "ClientSecretCredential".to_owned()) + }), + ) + .unwrap() +} + +#[derive(Deserialize)] +struct Fixture { + cases: Vec, +} + +#[derive(Deserialize)] +struct FixtureCase { + secret_name: String, + response: FixtureResponse, + expected: FixtureExpected, +} + +#[derive(Deserialize)] +struct FixtureResponse { + status: u16, + body: serde_json::Value, +} + +#[derive(Deserialize)] +struct FixtureExpected { + value: Option, + missing: Option, + error: Option, +} + +#[tokio::test] +async fn parity_fixture_matches_python_backend_contract() { + let fixture: Fixture = + serde_json::from_str(include_str!("fixtures/key_vault_parity.json")).unwrap(); + for case in fixture.cases { + let server = MockServer::start().await; + Mock::given(path(format!("/secrets/{}", case.secret_name))) + .respond_with( + ResponseTemplate::new(case.response.status).set_body_json(case.response.body), + ) + .expect(1) + .mount(&server) + .await; + let result = manager(&server) + .get_secret_from_azure_key_vault(&case.secret_name) + .await; + if case.expected.missing == Some(true) { + assert_eq!(result.unwrap(), None); + } else if case.expected.error == Some(true) { + assert!(result.is_err()); + } else { + assert_eq!( + result.unwrap().unwrap().as_str(), + case.expected.value.as_deref() + ); + } + } +} diff --git a/litellm-rust/crates/secrets-azure/tests/live.rs b/litellm-rust/crates/secrets-azure/tests/live.rs new file mode 100644 index 00000000000..a062ba95070 --- /dev/null +++ b/litellm-rust/crates/secrets-azure/tests/live.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use litellm_core_utils::settings::ProcessEnvironment; +use litellm_secrets_azure::AzureKeyVault; +use litellm_secrets_types::Secret; + +#[tokio::test] +#[ignore] +async fn reads_a_real_secret() { + let environment = Arc::new(ProcessEnvironment); + let manager = AzureKeyVault::new(environment).unwrap(); + let name = std::env::var("AZURE_KEY_VAULT_LIVE_SECRET_NAME").unwrap(); + let secret = manager + .get_secret_from_azure_key_vault(&name) + .await + .unwrap() + .unwrap(); + assert!(matches!(&secret, Secret::String(_))); + let host = std::env::var("AZURE_KEY_VAULT_URI") + .unwrap() + .parse::() + .unwrap() + .host_str() + .unwrap() + .to_owned(); + let value_len = secret.as_str().unwrap().len(); + println!( + "native provider=litellm-secrets-azure vault_host={host} secret={name} value_len={value_len}" + ); +} diff --git a/litellm-rust/crates/secrets/Cargo.toml b/litellm-rust/crates/secrets/Cargo.toml index 1298cbd33fe..962f66c92d1 100644 --- a/litellm-rust/crates/secrets/Cargo.toml +++ b/litellm-rust/crates/secrets/Cargo.toml @@ -9,12 +9,14 @@ repository.workspace = true default = [] aws = ["dep:litellm-secrets-aws"] google = ["dep:litellm-secrets-google"] +azure = ["dep:litellm-secrets-azure"] cyberark = ["dep:litellm-secrets-cyberark"] [dependencies] litellm-secrets-types.workspace = true litellm-secrets-aws = { workspace = true, optional = true } litellm-secrets-google = { workspace = true, optional = true } +litellm-secrets-azure = { workspace = true, optional = true } litellm-secrets-cyberark = { workspace = true, optional = true } litellm-core-utils.workspace = true base64.workspace = true diff --git a/litellm-rust/crates/secrets/src/error.rs b/litellm-rust/crates/secrets/src/error.rs index cf08c7c2abb..7e03f1f8cbf 100644 --- a/litellm-rust/crates/secrets/src/error.rs +++ b/litellm-rust/crates/secrets/src/error.rs @@ -30,6 +30,9 @@ pub enum Error { #[cfg(feature = "google")] #[error(transparent)] Google(#[from] litellm_secrets_google::Error), + #[cfg(feature = "azure")] + #[error(transparent)] + Azure(#[from] litellm_secrets_azure::Error), #[cfg(feature = "cyberark")] #[error(transparent)] Cyberark(#[from] litellm_secrets_cyberark::Error), diff --git a/litellm-rust/crates/secrets/src/handler.rs b/litellm-rust/crates/secrets/src/handler.rs index 190a67b7671..71214ffc9ce 100644 --- a/litellm-rust/crates/secrets/src/handler.rs +++ b/litellm-rust/crates/secrets/src/handler.rs @@ -13,6 +13,8 @@ pub enum SecretManager { GoogleKms(crate::google::GoogleKms), #[cfg(feature = "google")] GoogleSecretManager(crate::google::GoogleSecretManager), + #[cfg(feature = "azure")] + AzureKeyVault(crate::azure::AzureKeyVault), #[cfg(feature = "cyberark")] Cyberark(crate::cyberark::CyberArkSecretManager), } @@ -29,6 +31,8 @@ impl SecretManager { Self::GoogleKms(_) => KeyManagementSystem::GoogleKms, #[cfg(feature = "google")] Self::GoogleSecretManager(_) => KeyManagementSystem::GoogleSecretManager, + #[cfg(feature = "azure")] + Self::AzureKeyVault(_) => KeyManagementSystem::AzureKeyVault, #[cfg(feature = "cyberark")] Self::Cyberark(_) => KeyManagementSystem::Cyberark, } @@ -82,6 +86,11 @@ pub async fn get_secret_from_manager( .get_secret_from_google_secret_manager(secret_name) .await .map_err(Error::from), + #[cfg(feature = "azure")] + SecretManager::AzureKeyVault(client) => client + .get_secret_from_azure_key_vault(secret_name) + .await + .map_err(Error::from), #[cfg(feature = "cyberark")] SecretManager::Cyberark(client) => client .async_read_secret(secret_name) diff --git a/litellm-rust/crates/secrets/src/lib.rs b/litellm-rust/crates/secrets/src/lib.rs index e132627345f..dec924abdd0 100644 --- a/litellm-rust/crates/secrets/src/lib.rs +++ b/litellm-rust/crates/secrets/src/lib.rs @@ -17,6 +17,8 @@ pub use state::{SecretManagerState, secret_manager_would_be_consulted}; #[cfg(feature = "aws")] pub use litellm_secrets_aws as aws; +#[cfg(feature = "azure")] +pub use litellm_secrets_azure as azure; #[cfg(feature = "cyberark")] pub use litellm_secrets_cyberark as cyberark; #[cfg(feature = "google")] diff --git a/litellm-rust/crates/secrets/tests/handler.rs b/litellm-rust/crates/secrets/tests/handler.rs index 8f996cad63f..fbe13721491 100644 --- a/litellm-rust/crates/secrets/tests/handler.rs +++ b/litellm-rust/crates/secrets/tests/handler.rs @@ -106,6 +106,67 @@ async fn google_handler_requires_canonical_base64_and_preserves_plaintext_whites )); } +#[cfg(feature = "azure")] +#[tokio::test] +async fn azure_handler_reads_missing_and_failed_secrets() { + use litellm_secrets::{ + Error, KeyManagementSettings, KeyManagementSystem, SecretManager, azure::AzureKeyVault, + get_secret_from_manager, + }; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{path, query_param}, + }; + + let server = MockServer::start().await; + Mock::given(path("/secrets/KEY")) + .and(query_param("api-version", "7.4")) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": "value"})), + ) + .expect(1) + .mount(&server) + .await; + let manager = SecretManager::AzureKeyVault( + AzureKeyVault::with_client( + reqwest::Client::new(), + server.uri().parse().unwrap(), + std::sync::Arc::new(|name: &str| (name == "AZURE_AD_TOKEN").then(|| "fake".to_owned())), + ) + .unwrap(), + ); + assert_eq!(manager.system(), KeyManagementSystem::AzureKeyVault); + let settings = KeyManagementSettings::default(); + let value = get_secret_from_manager(&manager, "KEY", &settings, &|_: &str| None) + .await + .unwrap() + .unwrap(); + assert_eq!(value.as_str(), Some("value")); + + let not_found = Mock::given(path("/secrets/MISSING")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount_as_scoped(&server) + .await; + assert_eq!( + get_secret_from_manager(&manager, "MISSING", &settings, &|_: &str| None) + .await + .unwrap(), + None + ); + drop(not_found); + + Mock::given(path("/secrets/FAILED")) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&server) + .await; + assert!(matches!( + get_secret_from_manager(&manager, "FAILED", &settings, &|_: &str| None).await, + Err(Error::Azure(_)) + )); +} + #[cfg(feature = "cyberark")] #[tokio::test] async fn cyberark_handler_reads_values_and_surfaces_errors() { diff --git a/tests/test_litellm/secret_managers/test_secret_manager_handler.py b/tests/test_litellm/secret_managers/test_secret_manager_handler.py new file mode 100644 index 00000000000..b4838912d27 --- /dev/null +++ b/tests/test_litellm/secret_managers/test_secret_manager_handler.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import pytest +from pydantic import BaseModel, ConfigDict + +from litellm.secret_managers.secret_manager_handler import get_secret_from_manager +from litellm.types.secret_managers.main import KeyManagementSystem + + +def _azure_exception_types() -> tuple[type[Exception], type[Exception]]: + try: + from azure.core.exceptions import ( + HttpResponseError, + ResourceNotFoundError, + ) + except ImportError: + return Exception, Exception + return HttpResponseError, ResourceNotFoundError + + +_AZURE_EXCEPTION_TYPES: Final[tuple[type[Exception], type[Exception]]] = _azure_exception_types() +AzureHttpResponseError: Final[type[Exception]] = _AZURE_EXCEPTION_TYPES[0] +AzureResourceNotFoundError: Final[type[Exception]] = _AZURE_EXCEPTION_TYPES[1] + + +class FixtureResponse(BaseModel): + model_config = ConfigDict(frozen=True) + + status: int + body: dict[str, object] + + +class FixtureExpected(BaseModel): + model_config = ConfigDict(frozen=True) + + value: str | None = None + missing: bool = False + error: bool = False + + +class FixtureCase(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str + secret_name: str + response: FixtureResponse + expected: FixtureExpected + + +class Fixture(BaseModel): + model_config = ConfigDict(frozen=True) + + cases: tuple[FixtureCase, ...] + + +@dataclass(frozen=True, slots=True) +class FakeSecret: + value: str | None + + +@dataclass(frozen=True, slots=True) +class FakeAzureKeyVaultClient: + status: int + value: str | None + + def get_secret(self, name: str) -> FakeSecret: + if self.status == 404: + raise AzureResourceNotFoundError() + if self.status != 200: + raise AzureHttpResponseError() + return FakeSecret(value=self.value) + + +FIXTURE_PATH: Path = ( + Path(__file__).parents[3] + / "litellm-rust/crates/secrets-azure/tests/fixtures/key_vault_parity.json" +) + + +def test_azure_key_vault_matches_rust_parity_fixture() -> None: + fixture: Fixture = Fixture.model_validate_json(FIXTURE_PATH.read_text()) + for case in fixture.cases: + value: object = case.response.body.get("value") + secret: str | None = value if isinstance(value, str) else None + client: FakeAzureKeyVaultClient = FakeAzureKeyVaultClient( + status=case.response.status, + value=secret, + ) + if case.expected.missing or case.expected.error: + with pytest.raises( + AzureResourceNotFoundError if case.expected.missing else AzureHttpResponseError + ): + get_secret_from_manager( + secret_name=case.secret_name, + key_manager=KeyManagementSystem.AZURE_KEY_VAULT.value, + client=client, + ) + continue + + result: str | None = get_secret_from_manager( + secret_name=case.secret_name, + key_manager=KeyManagementSystem.AZURE_KEY_VAULT.value, + client=client, + ) + assert result == case.expected.value