#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::LazyLock;
use ahash::AHashMap;
use liter_llm::client::ClientConfig;
use liter_llm::{LlmInFlightLimitConfig, ManagedClient};
use parking_lot::RwLock;
#[cfg(not(target_arch = "wasm32"))]
use crate::core::config::CredentialProviderConfig;
use crate::core::config::{
BedrockConfig, LlmBudgetConfig, LlmCacheConfig, LlmConfig, LlmProviderConfig, LlmRateLimitConfig,
};
fn to_liter_llm_bedrock(bedrock: &BedrockConfig) -> liter_llm::client::BedrockConfig {
liter_llm::client::BedrockConfig {
region: bedrock.region.clone(),
cross_region_prefix: bedrock.cross_region_prefix.clone(),
access_key_id: bedrock.access_key_id.clone(),
secret_access_key: bedrock.secret_access_key.clone(),
session_token: bedrock.session_token.clone(),
}
}
fn to_liter_llm_provider(provider: &LlmProviderConfig) -> liter_llm::client::LlmProviderConfig {
liter_llm::client::LlmProviderConfig {
name: provider.name.clone(),
base_url: provider.base_url.clone(),
auth_header: provider.auth_header.clone(),
model_prefixes: provider.model_prefixes.clone(),
}
}
fn to_auth_header_format(auth_header: Option<&str>) -> liter_llm::AuthHeaderFormat {
match auth_header {
None => liter_llm::AuthHeaderFormat::Bearer,
Some(name) if name.eq_ignore_ascii_case("authorization") => liter_llm::AuthHeaderFormat::Bearer,
Some(name) => liter_llm::AuthHeaderFormat::ApiKey(name.to_string()),
}
}
fn register_configured_providers(config: &LlmConfig) -> crate::Result<()> {
let Some(providers) = config.providers.as_ref() else {
return Ok(());
};
for provider in providers {
let custom = liter_llm::CustomProviderConfig {
name: provider.name.clone(),
base_url: provider.base_url.clone(),
auth_header: to_auth_header_format(provider.auth_header.as_deref()),
model_prefixes: provider.model_prefixes.clone(),
};
liter_llm::register_custom_provider(custom).map_err(|e| {
let msg = format!("Failed to register custom LLM provider '{}': {e}", provider.name);
crate::XbergError::Validation {
message: msg,
source: Some(Box::new(e)),
}
})?;
}
Ok(())
}
pub(crate) fn parse_reasoning_effort(config: &LlmConfig) -> crate::Result<Option<liter_llm::ReasoningEffort>> {
let Some(value) = config.reasoning_effort.as_deref() else {
return Ok(None);
};
match value.to_ascii_lowercase().as_str() {
"low" => Ok(Some(liter_llm::ReasoningEffort::Low)),
"medium" => Ok(Some(liter_llm::ReasoningEffort::Medium)),
"high" => Ok(Some(liter_llm::ReasoningEffort::High)),
"minimal" => Ok(Some(liter_llm::ReasoningEffort::Minimal)),
"max" => Ok(Some(liter_llm::ReasoningEffort::Max)),
_ => Err(crate::XbergError::Validation {
message: format!(
"Invalid LLM reasoning_effort '{value}': expected one of \"low\", \"medium\", \"high\", \
\"minimal\", \"max\""
),
source: None,
}),
}
}
pub(crate) fn to_stop_sequence(config: &LlmConfig) -> Option<liter_llm::StopSequence> {
config
.stop
.as_ref()
.map(|sequences| liter_llm::StopSequence::Multiple(sequences.clone()))
}
#[cfg_attr(alef, alef(skip))]
pub(crate) fn apply_request_time_params(
request: &mut liter_llm::ChatCompletionRequest,
config: &LlmConfig,
) -> crate::Result<()> {
let reasoning_effort = parse_reasoning_effort(config)?;
request.temperature = config.temperature;
request.max_tokens = config.max_tokens;
request.top_p = config.top_p;
request.stop = to_stop_sequence(config);
request.seed = config.seed;
request.presence_penalty = config.presence_penalty;
request.frequency_penalty = config.frequency_penalty;
request.reasoning_effort = reasoning_effort;
request.extra_body = config.extra_body.clone();
Ok(())
}
fn to_liter_llm_cache(cache: &LlmCacheConfig) -> liter_llm::client::LlmCacheConfig {
liter_llm::client::LlmCacheConfig {
max_entries: cache.max_entries,
ttl_seconds: cache.ttl_seconds,
backend: cache.backend.clone(),
backend_config: cache.backend_config.clone(),
}
}
fn to_liter_llm_budget(budget: &LlmBudgetConfig) -> liter_llm::client::LlmBudgetConfig {
liter_llm::client::LlmBudgetConfig {
global_limit: budget.global_limit,
model_limits: budget.model_limits.clone(),
enforcement: budget.enforcement.clone(),
}
}
fn to_liter_llm_rate_limit(rate_limit: &LlmRateLimitConfig) -> liter_llm::client::LlmRateLimitConfig {
liter_llm::client::LlmRateLimitConfig {
rpm: rate_limit.rpm,
tpm: rate_limit.tpm,
window_seconds: rate_limit.window_seconds,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn build_credential_provider(
config: &CredentialProviderConfig,
) -> crate::Result<Arc<dyn liter_llm::auth::CredentialProvider>> {
match config {
CredentialProviderConfig::AzureAd {
tenant_id,
client_id,
client_secret,
scope,
} => Ok(build_azure_ad_provider(
tenant_id,
client_id,
client_secret,
scope.as_deref(),
)),
CredentialProviderConfig::VertexOauth2 {
service_account_key_file,
scope,
} => build_vertex_oauth2_provider(service_account_key_file, scope.as_deref()),
CredentialProviderConfig::VertexAdc { scope } => Ok(build_vertex_adc_provider(scope.as_deref())),
CredentialProviderConfig::BedrockWebIdentity {
role_arn,
token_file,
session_name,
region,
} => Ok(build_bedrock_web_identity_provider(
role_arn,
token_file,
session_name.as_deref(),
region.as_deref(),
)),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn build_azure_ad_provider(
tenant_id: &str,
client_id: &str,
client_secret: &str,
scope: Option<&str>,
) -> Arc<dyn liter_llm::auth::CredentialProvider> {
let mut provider = liter_llm::auth::azure_ad::AzureAdCredentialProvider::new(
tenant_id.to_string(),
client_id.to_string(),
secrecy::SecretString::from(client_secret.to_string()),
);
if let Some(scope) = scope {
provider = provider.with_scope(scope.to_string());
}
Arc::new(provider)
}
#[cfg(not(target_arch = "wasm32"))]
fn build_vertex_oauth2_provider(
service_account_key_file: &str,
scope: Option<&str>,
) -> crate::Result<Arc<dyn liter_llm::auth::CredentialProvider>> {
let mut provider = liter_llm::auth::vertex_oauth::VertexOAuthCredentialProvider::from_key_file(
std::path::Path::new(service_account_key_file),
)
.map_err(|e| crate::XbergError::Validation {
message: format!("Failed to load Vertex OAuth2 service account key file '{service_account_key_file}': {e}"),
source: Some(Box::new(e)),
})?;
if let Some(scope) = scope {
provider = provider.with_scope(scope.to_string());
}
Ok(Arc::new(provider))
}
#[cfg(not(target_arch = "wasm32"))]
fn build_vertex_adc_provider(scope: Option<&str>) -> Arc<dyn liter_llm::auth::CredentialProvider> {
let mut provider = liter_llm::auth::vertex_adc::VertexAdcCredentialProvider::new();
if let Some(scope) = scope {
provider = provider.with_scope(scope.to_string());
}
Arc::new(provider)
}
#[cfg(not(target_arch = "wasm32"))]
fn build_bedrock_web_identity_provider(
role_arn: &str,
token_file: &str,
session_name: Option<&str>,
region: Option<&str>,
) -> Arc<dyn liter_llm::auth::CredentialProvider> {
Arc::new(liter_llm::auth::bedrock_sts::WebIdentityCredentialProvider::new(
role_arn.to_string(),
token_file.to_string(),
session_name.unwrap_or("liter-llm-session").to_string(),
region.unwrap_or("us-east-1").to_string(),
))
}
const SUPPORTED_CACHE_BACKEND: &str = "memory";
fn validate_cache_backend(cache: &LlmCacheConfig) -> crate::Result<()> {
match cache.backend.as_deref() {
None | Some(SUPPORTED_CACHE_BACKEND) => Ok(()),
Some(other) => Err(crate::XbergError::Validation {
message: format!(
"Unsupported LLM cache backend '{other}': xberg only supports \"{SUPPORTED_CACHE_BACKEND}\" \
(liter-llm's `opendal-cache` feature, which unlocks Redis/S3/filesystem-backed caches, is not \
compiled in). Use \"{SUPPORTED_CACHE_BACKEND}\" or omit `backend`."
),
source: None,
}),
}
}
fn to_liter_llm_config(config: &LlmConfig) -> liter_llm::client::LlmConfig {
liter_llm::client::LlmConfig {
model: config.model.clone(),
api_key: config.api_key.clone(),
base_url: config
.base_url
.as_deref()
.map(|url| url.trim_end_matches('/').to_string()),
timeout_secs: config.timeout_secs,
max_retries: config.max_retries,
temperature: config.temperature,
max_tokens: config.max_tokens,
load_env: config.load_env,
headers: None,
providers: config
.providers
.as_ref()
.map(|providers| providers.iter().map(to_liter_llm_provider).collect()),
cache: config.cache.as_deref().map(to_liter_llm_cache),
budget: config.budget.as_deref().map(to_liter_llm_budget),
rate_limit: config.rate_limit.as_deref().map(to_liter_llm_rate_limit),
in_flight_limit: config.max_concurrency.map(|max| LlmInFlightLimitConfig {
max_in_flight: Some(max),
}),
cost_tracking: config.cost_tracking,
tracing: config.tracing,
cooldown_secs: config.cooldown_secs,
health_check_secs: config.health_check_secs,
bedrock: config.bedrock.as_deref().map(to_liter_llm_bedrock),
}
}
fn build_client_config(config: &LlmConfig) -> crate::Result<ClientConfig> {
config.validate()?;
register_configured_providers(config)?;
if let Some(ref cache) = config.cache {
validate_cache_backend(cache)?;
}
let mut builder = to_liter_llm_config(config).into_client_builder();
#[cfg(not(target_arch = "wasm32"))]
{
builder = apply_credential_provider(config, builder)?;
}
if let Some(ref headers) = config.headers {
for (key, value) in headers {
builder = builder.header(key.as_str(), value.as_str()).map_err(|e| {
let msg = format!("Invalid LLM header '{key}': {e}");
crate::XbergError::Validation {
message: msg,
source: Some(Box::new(e)),
}
})?;
}
}
Ok(builder.build())
}
#[cfg(not(target_arch = "wasm32"))]
fn apply_credential_provider(
config: &LlmConfig,
builder: liter_llm::client::ClientConfigBuilder,
) -> crate::Result<liter_llm::client::ClientConfigBuilder> {
let Some(provider_config) = config.credential_provider.as_deref() else {
return Ok(builder);
};
let provider = build_credential_provider(provider_config)?;
Ok(builder.credential_provider(provider))
}
fn build_managed_client(config: &LlmConfig, client_config: ClientConfig) -> crate::Result<ManagedClient> {
ManagedClient::new(client_config, Some(&config.model)).map_err(|e| {
let msg = format!("Failed to build LLM client for model '{}': {e}", config.model);
crate::XbergError::Validation {
message: msg,
source: Some(Box::new(e)),
}
})
}
#[cfg(not(target_arch = "wasm32"))]
static LLM_CLIENT_POOL: LazyLock<RwLock<AHashMap<[u8; 32], Arc<ManagedClient>>>> =
LazyLock::new(|| RwLock::new(AHashMap::new()));
#[cfg(not(target_arch = "wasm32"))]
const MAX_CACHED_LLM_CLIENTS: usize = 256;
#[cfg(not(target_arch = "wasm32"))]
fn config_digest(config: &LlmConfig) -> crate::Result<[u8; 32]> {
let serialized = serde_json::to_vec(config).map_err(|e| crate::XbergError::Validation {
message: format!("Failed to serialize LLM config for client-cache lookup: {e}"),
source: Some(Box::new(e)),
})?;
Ok(*blake3::hash(&serialized).as_bytes())
}
#[cfg(not(target_arch = "wasm32"))]
fn cached_managed_client(config: &LlmConfig) -> crate::Result<Arc<ManagedClient>> {
let digest = config_digest(config)?;
{
let pool = LLM_CLIENT_POOL.read();
if let Some(client) = pool.get(&digest) {
return Ok(Arc::clone(client));
}
}
let client_config = build_client_config(config)?;
let client = Arc::new(build_managed_client(config, client_config)?);
let mut pool = LLM_CLIENT_POOL.write();
if let Some(existing) = pool.get(&digest) {
return Ok(Arc::clone(existing));
}
if pool.len() < MAX_CACHED_LLM_CLIENTS {
pool.insert(digest, Arc::clone(&client));
} else {
tracing::debug!(
cached = pool.len(),
limit = MAX_CACHED_LLM_CLIENTS,
"LLM client cache at capacity; built an unpooled client for this call"
);
}
Ok(client)
}
pub(crate) fn create_client(config: &LlmConfig) -> crate::Result<Arc<ManagedClient>> {
cached_managed_client(config)
}
#[cfg(not(target_arch = "wasm32"))]
#[cfg_attr(alef, alef(skip))]
pub fn create_client_with_credential_provider(
config: &LlmConfig,
provider: Arc<dyn liter_llm::auth::CredentialProvider>,
) -> crate::Result<ManagedClient> {
let mut client_config = build_client_config(config)?;
client_config.credential_provider = Some(provider);
build_managed_client(config, client_config)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::{BedrockConfig, CredentialProviderConfig, LlmConfig};
#[cfg(feature = "api")]
#[tokio::test]
async fn test_client_path_normalization_with_base_url() {
use axum::{Router, routing::post};
use liter_llm::LlmClient;
use tokio::sync::mpsc;
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
let app = Router::new().fallback(post(
move |_method: axum::http::Method, uri: axum::http::Uri, headers: axum::http::HeaderMap| async move {
assert_eq!(uri.path(), "/v1/chat/completions");
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("none")
.to_string();
let _ = tx.send(auth);
axum::response::Json(serde_json::json!({
"id": "test",
"object": "chat.completion",
"created": 12345,
"model": "test",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "{\"foo\": \"bar\"}" },
"finish_reason": "stop"
}]
}))
},
));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let base_url = format!("http://{}/v1/", addr);
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-api-key".to_string()),
base_url: Some(base_url),
..LlmConfig::default()
};
let client = create_client(&config).unwrap();
let request = liter_llm::ChatCompletionRequest {
model: config.model.clone(),
messages: vec![liter_llm::Message::User(liter_llm::UserMessage {
content: liter_llm::UserContent::Text("test".to_string()),
..Default::default()
})],
..Default::default()
};
let _ = client.chat(request).await.expect("Request failed");
let auth_header = tokio::time::timeout(tokio::time::Duration::from_secs(5), rx.recv())
.await
.expect("Timeout waiting for header")
.expect("No header received");
assert_eq!(auth_header, "Bearer test-api-key");
}
#[test]
fn test_create_client_sanitizes_base_url() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
base_url: Some("https://api.openai.com/v1/".to_string()),
..LlmConfig::default()
};
let _ = create_client(&config).unwrap();
}
#[test]
fn test_create_client_applies_load_env_and_valid_headers() {
let mut headers = std::collections::HashMap::new();
headers.insert("X-Gateway-Key".to_string(), "secret123".to_string());
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
load_env: Some(true),
headers: Some(headers),
..LlmConfig::default()
};
assert!(
create_client(&config).is_ok(),
"valid load_env + headers should build a client"
);
}
#[test]
fn test_create_client_rejects_invalid_header() {
let mut headers = std::collections::HashMap::new();
headers.insert("X-Bad\r\nInjected".to_string(), "value".to_string());
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
headers: Some(headers),
..LlmConfig::default()
};
match create_client(&config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(message.contains("Invalid LLM header"), "unexpected message: {message}");
}
Err(other) => panic!("expected a Validation error, got: {other}"),
Ok(_) => panic!("expected create_client to reject the invalid header"),
}
}
#[test]
fn test_create_client_shares_one_instance_for_identical_config() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key-for-sharing-test".to_string()),
..LlmConfig::default()
};
let first = create_client(&config).expect("build first client");
let second = create_client(&config).expect("build second client");
assert!(
Arc::ptr_eq(&first, &second),
"two create_client calls with an identical LlmConfig must return the same client \
instance so an in-flight-request limit is enforced globally, not per call"
);
}
#[test]
fn test_create_client_does_not_share_instance_across_different_configs() {
let config_a = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key-a-for-sharing-test".to_string()),
..LlmConfig::default()
};
let config_b = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key-b-for-sharing-test".to_string()),
..LlmConfig::default()
};
let client_a = create_client(&config_a).expect("build client for config a");
let client_b = create_client(&config_b).expect("build client for config b");
assert!(
!Arc::ptr_eq(&client_a, &client_b),
"create_client must not share a client instance across different LlmConfigs"
);
}
fn bedrock_model_config(bedrock: BedrockConfig) -> LlmConfig {
LlmConfig {
model: "bedrock/anthropic.claude-3-sonnet-20240229-v1:0".to_string(),
bedrock: Some(Box::new(bedrock)),
..LlmConfig::default()
}
}
#[test]
fn test_build_client_config_maps_bedrock_region_and_cross_region_prefix() {
let config = bedrock_model_config(BedrockConfig {
region: Some("eu-central-1".to_string()),
cross_region_prefix: Some("eu".to_string()),
..BedrockConfig::default()
});
let client_config = build_client_config(&config).expect("build client config");
assert_eq!(client_config.bedrock_region.as_deref(), Some("eu-central-1"));
assert_eq!(client_config.bedrock_cross_region_prefix.as_deref(), Some("eu"));
assert_eq!(client_config.bedrock_access_key_id, None);
assert_eq!(client_config.bedrock_secret_access_key, None);
assert_eq!(client_config.bedrock_session_token, None);
}
#[test]
fn test_build_client_config_maps_bedrock_credentials() {
let config = bedrock_model_config(BedrockConfig {
region: Some("us-east-1".to_string()),
access_key_id: Some("AKIAEXAMPLE".to_string()),
secret_access_key: Some("example-secret".to_string()),
session_token: Some("example-token".to_string()),
..BedrockConfig::default()
});
let client_config = build_client_config(&config).expect("build client config");
assert_eq!(client_config.bedrock_region.as_deref(), Some("us-east-1"));
assert_eq!(client_config.bedrock_access_key_id.as_deref(), Some("AKIAEXAMPLE"));
assert_eq!(
client_config.bedrock_secret_access_key.as_deref(),
Some("example-secret")
);
assert_eq!(client_config.bedrock_session_token.as_deref(), Some("example-token"));
}
#[test]
fn test_build_client_config_maps_bedrock_credentials_without_session_token() {
let config = bedrock_model_config(BedrockConfig {
access_key_id: Some("AKIAEXAMPLE".to_string()),
secret_access_key: Some("example-secret".to_string()),
..BedrockConfig::default()
});
let client_config = build_client_config(&config).expect("build client config");
assert_eq!(client_config.bedrock_access_key_id.as_deref(), Some("AKIAEXAMPLE"));
assert_eq!(
client_config.bedrock_secret_access_key.as_deref(),
Some("example-secret")
);
assert_eq!(client_config.bedrock_session_token, None);
}
#[test]
fn test_build_client_config_leaves_bedrock_unset_when_absent() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
assert_eq!(client_config.bedrock_region, None);
assert_eq!(client_config.bedrock_cross_region_prefix, None);
assert_eq!(client_config.bedrock_access_key_id, None);
assert_eq!(client_config.bedrock_secret_access_key, None);
assert_eq!(client_config.bedrock_session_token, None);
}
#[allow(unsafe_code)]
#[serial_test::serial]
#[test]
fn test_create_client_reports_clear_error_for_unconfigured_bedrock() {
let original = std::env::var("AWS_ACCESS_KEY_ID").ok();
unsafe {
std::env::remove_var("AWS_ACCESS_KEY_ID");
}
let config = bedrock_model_config(BedrockConfig::default());
let result = create_client(&config);
unsafe {
match &original {
Some(val) => std::env::set_var("AWS_ACCESS_KEY_ID", val),
None => std::env::remove_var("AWS_ACCESS_KEY_ID"),
}
}
match result {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(
message.contains("bedrock/anthropic.claude-3-sonnet-20240229-v1:0"),
"error should name the model (the input) that failed to build: {message}"
);
assert!(
message.contains("AWS credentials"),
"error should name the root cause (missing AWS credentials): {message}"
);
assert!(
message.contains("AWS_ACCESS_KEY_ID") && message.contains("AWS_SECRET_ACCESS_KEY"),
"error should suggest how to fix it (set explicit config or the AWS env vars): {message}"
);
}
Err(other) => panic!("expected a Validation error, got: {other}"),
Ok(_) => panic!("expected create_client to reject an unconfigured bedrock model"),
}
}
#[test]
fn test_build_client_config_debug_never_leaks_bedrock_credentials() {
let config = bedrock_model_config(BedrockConfig {
region: Some("us-east-1".to_string()),
access_key_id: Some("AKIAEXAMPLEFAKE".to_string()),
secret_access_key: Some("example-fake-secret".to_string()),
session_token: Some("example-fake-token".to_string()),
..BedrockConfig::default()
});
let client_config = build_client_config(&config).expect("build client config");
let rendered = format!("{client_config:?}");
for secret in ["AKIAEXAMPLEFAKE", "example-fake-secret", "example-fake-token"] {
assert!(
!rendered.contains(secret),
"liter-llm ClientConfig Debug leaked {secret}: {rendered}"
);
}
assert!(
rendered.contains("us-east-1"),
"non-secret region should stay visible for diagnosability: {rendered}"
);
}
#[test]
fn test_create_client_error_never_leaks_bedrock_credentials() {
let mut headers = std::collections::HashMap::new();
headers.insert("X-Bad\r\nInjected".to_string(), "value".to_string());
let mut config = bedrock_model_config(BedrockConfig {
region: Some("us-east-1".to_string()),
access_key_id: Some("AKIAEXAMPLEFAKE".to_string()),
secret_access_key: Some("example-fake-secret".to_string()),
session_token: Some("example-fake-token".to_string()),
..BedrockConfig::default()
});
config.headers = Some(headers);
let Err(err) = create_client(&config) else {
panic!("invalid header must reject the request");
};
let rendered = format!("{err}");
for secret in ["AKIAEXAMPLEFAKE", "example-fake-secret", "example-fake-token"] {
assert!(
!rendered.contains(secret),
"create_client error leaked {secret}: {rendered}"
);
}
}
#[test]
fn test_build_client_config_maps_cache_settings() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
cache: Some(Box::new(LlmCacheConfig {
max_entries: Some(128),
ttl_seconds: Some(90),
backend: Some("memory".to_string()),
backend_config: None,
})),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
let cache = client_config.cache_config.expect("cache_config present");
assert_eq!(cache.max_entries, 128);
assert_eq!(cache.ttl, std::time::Duration::from_secs(90));
assert!(matches!(cache.backend, liter_llm::tower::CacheBackend::Memory));
}
#[test]
fn test_build_client_config_rejects_unsupported_cache_backend_without_opendal_cache() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
cache: Some(Box::new(LlmCacheConfig {
backend: Some("s3".to_string()),
..LlmCacheConfig::default()
})),
..LlmConfig::default()
};
match build_client_config(&config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(message.contains("Unsupported LLM cache backend 's3'"), "{message}");
assert!(message.contains("opendal-cache"), "{message}");
}
Err(other) => panic!("expected a Validation error, got: {other}"),
Ok(_) => panic!("expected build_client_config to reject the unsupported cache backend"),
}
}
#[test]
fn test_build_client_config_accepts_explicit_memory_cache_backend() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
cache: Some(Box::new(LlmCacheConfig {
backend: Some("memory".to_string()),
..LlmCacheConfig::default()
})),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
let cache = client_config.cache_config.expect("cache_config present");
assert!(matches!(cache.backend, liter_llm::tower::CacheBackend::Memory));
}
#[test]
fn test_build_client_config_accepts_unset_cache_backend() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
cache: Some(Box::new(LlmCacheConfig::default())),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
let cache = client_config.cache_config.expect("cache_config present");
assert!(matches!(cache.backend, liter_llm::tower::CacheBackend::Memory));
}
#[test]
fn test_build_client_config_maps_budget_settings() {
let mut model_limits = std::collections::HashMap::new();
model_limits.insert("openai/gpt-4o".to_string(), 25.0);
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
budget: Some(Box::new(LlmBudgetConfig {
global_limit: Some(100.0),
model_limits: Some(model_limits),
enforcement: Some("soft".to_string()),
})),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
let budget = client_config.budget_config.expect("budget_config present");
assert_eq!(budget.global_limit, Some(100.0));
assert_eq!(budget.model_limits.get("openai/gpt-4o"), Some(&25.0));
assert_eq!(budget.enforcement, liter_llm::tower::Enforcement::Soft);
}
#[test]
fn test_build_client_config_defaults_unknown_enforcement_to_hard() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
budget: Some(Box::new(LlmBudgetConfig {
global_limit: Some(10.0),
enforcement: Some("not-a-real-mode".to_string()),
..LlmBudgetConfig::default()
})),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
let budget = client_config.budget_config.expect("budget_config present");
assert_eq!(budget.enforcement, liter_llm::tower::Enforcement::Hard);
}
#[test]
fn test_build_client_config_maps_rate_limit_settings() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
rate_limit: Some(Box::new(LlmRateLimitConfig {
rpm: Some(60),
tpm: Some(100_000),
window_seconds: Some(30),
})),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
let rate_limit = client_config.rate_limit_config.expect("rate_limit_config present");
assert_eq!(rate_limit.rpm, Some(60));
assert_eq!(rate_limit.tpm, Some(100_000));
assert_eq!(rate_limit.window, std::time::Duration::from_secs(30));
}
#[test]
fn test_build_client_config_maps_cost_tracking_and_tracing_flags() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
cost_tracking: Some(true),
tracing: Some(true),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
assert!(client_config.enable_cost_tracking);
assert!(client_config.enable_tracing);
}
#[test]
fn test_build_client_config_maps_cooldown_and_health_check_intervals() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
cooldown_secs: Some(15),
health_check_secs: Some(45),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
assert_eq!(
client_config.cooldown_duration,
Some(std::time::Duration::from_secs(15))
);
assert_eq!(
client_config.health_check_interval,
Some(std::time::Duration::from_secs(45))
);
}
#[test]
fn test_build_client_config_leaves_new_passthrough_fields_unset_when_absent() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
assert!(client_config.cache_config.is_none());
assert!(client_config.budget_config.is_none());
assert!(client_config.rate_limit_config.is_none());
assert!(client_config.cooldown_duration.is_none());
assert!(client_config.health_check_interval.is_none());
assert!(!client_config.enable_cost_tracking);
assert!(!client_config.enable_tracing);
}
#[test]
fn test_build_client_config_still_validates_headers_with_tower_fields_set() {
let mut headers = std::collections::HashMap::new();
headers.insert("X-Bad\r\nInjected".to_string(), "value".to_string());
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
headers: Some(headers),
cost_tracking: Some(true),
..LlmConfig::default()
};
match build_client_config(&config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(message.contains("Invalid LLM header"), "unexpected message: {message}");
}
Err(other) => panic!("expected a Validation error, got: {other}"),
Ok(_) => panic!("expected build_client_config to reject the invalid header"),
}
}
#[test]
fn test_to_liter_llm_config_maps_max_concurrency_to_in_flight_limit() {
let with_limit = LlmConfig {
model: "openai/gpt-4o".to_string(),
max_concurrency: Some(4),
..LlmConfig::default()
};
assert_eq!(
to_liter_llm_config(&with_limit).in_flight_limit,
Some(LlmInFlightLimitConfig { max_in_flight: Some(4) })
);
let without_limit = LlmConfig {
model: "openai/gpt-4o".to_string(),
max_concurrency: None,
..LlmConfig::default()
};
assert_eq!(to_liter_llm_config(&without_limit).in_flight_limit, None);
}
#[test]
fn test_to_liter_llm_config_forwards_providers_into_the_dto_boundary() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
providers: Some(vec![LlmProviderConfig {
name: "my-provider".to_string(),
base_url: "https://my-llm.example.com/v1".to_string(),
auth_header: Some("X-Api-Key".to_string()),
model_prefixes: vec!["my-provider/".to_string()],
}]),
..LlmConfig::default()
};
let upstream = to_liter_llm_config(&config);
let providers = upstream.providers.expect("providers present");
assert_eq!(providers.len(), 1);
assert_eq!(providers[0].name, "my-provider");
assert_eq!(providers[0].base_url, "https://my-llm.example.com/v1");
assert_eq!(providers[0].auth_header.as_deref(), Some("X-Api-Key"));
assert_eq!(providers[0].model_prefixes, vec!["my-provider/".to_string()]);
}
#[test]
fn test_to_auth_header_format_maps_none_and_authorization_to_bearer() {
assert!(matches!(
to_auth_header_format(None),
liter_llm::AuthHeaderFormat::Bearer
));
assert!(matches!(
to_auth_header_format(Some("Authorization")),
liter_llm::AuthHeaderFormat::Bearer
));
assert!(matches!(
to_auth_header_format(Some("authorization")),
liter_llm::AuthHeaderFormat::Bearer
));
}
#[test]
fn test_to_auth_header_format_maps_custom_header_name_to_api_key() {
match to_auth_header_format(Some("X-Api-Key")) {
liter_llm::AuthHeaderFormat::ApiKey(name) => assert_eq!(name, "X-Api-Key"),
other => panic!("expected ApiKey variant, got {other:?}"),
}
}
#[test]
fn test_build_client_config_registers_configured_providers_in_the_liter_llm_registry() {
let provider_name = "xberg-test-provider-registers-in-registry";
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
providers: Some(vec![LlmProviderConfig {
name: provider_name.to_string(),
base_url: "https://my-llm.example.com/v1".to_string(),
auth_header: Some("X-Api-Key".to_string()),
model_prefixes: vec!["xberg-test-provider-registers/".to_string()],
}]),
..LlmConfig::default()
};
build_client_config(&config).expect("build client config");
let removed = liter_llm::unregister_custom_provider(provider_name).expect("unregister should succeed");
assert!(removed, "expected provider '{provider_name}' to have been registered");
let removed_again = liter_llm::unregister_custom_provider(provider_name).expect("unregister should succeed");
assert!(
!removed_again,
"provider should already be gone after the first unregister"
);
}
#[test]
fn test_build_client_config_provider_registration_is_idempotent_for_same_config() {
let provider_name = "xberg-test-provider-idempotent";
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
providers: Some(vec![LlmProviderConfig {
name: provider_name.to_string(),
base_url: "https://my-llm.example.com/v1".to_string(),
auth_header: None,
model_prefixes: vec!["xberg-test-provider-idempotent/".to_string()],
}]),
..LlmConfig::default()
};
build_client_config(&config).expect("first registration should succeed");
build_client_config(&config).expect("second registration with identical config should succeed");
let removed = liter_llm::unregister_custom_provider(provider_name).expect("unregister should succeed");
assert!(removed);
}
#[test]
fn test_build_client_config_rejects_invalid_provider_registration() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
providers: Some(vec![LlmProviderConfig {
name: "xberg-test-provider-invalid".to_string(),
base_url: "https://my-llm.example.com/v1".to_string(),
auth_header: None,
model_prefixes: vec![],
}]),
..LlmConfig::default()
};
match build_client_config(&config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(
message.contains("xberg-test-provider-invalid"),
"error should name the provider: {message}"
);
}
Err(other) => panic!("expected a Validation error, got: {other}"),
Ok(_) => panic!("expected build_client_config to reject the invalid provider config"),
}
}
#[test]
fn test_parse_reasoning_effort_maps_known_values_case_insensitively() {
let cases = [
("low", liter_llm::ReasoningEffort::Low),
("MEDIUM", liter_llm::ReasoningEffort::Medium),
("High", liter_llm::ReasoningEffort::High),
("minimal", liter_llm::ReasoningEffort::Minimal),
("max", liter_llm::ReasoningEffort::Max),
];
for (input, expected) in cases {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
reasoning_effort: Some(input.to_string()),
..LlmConfig::default()
};
assert_eq!(
parse_reasoning_effort(&config).expect("known value should parse"),
Some(expected),
"input {input:?} mapped incorrectly"
);
}
}
#[test]
fn test_parse_reasoning_effort_returns_none_when_unset() {
let config = LlmConfig::default();
assert_eq!(parse_reasoning_effort(&config).expect("no value should parse"), None);
}
#[test]
fn test_parse_reasoning_effort_rejects_unrecognized_value() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
reasoning_effort: Some("maximum".to_string()),
..LlmConfig::default()
};
match parse_reasoning_effort(&config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(
message.contains("maximum"),
"error should name the bad value: {message}"
);
}
other => panic!("expected a Validation error, got: {other:?}"),
}
}
#[test]
fn test_to_stop_sequence_returns_none_when_unset() {
let config = LlmConfig::default();
assert!(to_stop_sequence(&config).is_none());
}
#[test]
fn test_to_stop_sequence_maps_single_entry_to_multiple_variant() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
stop: Some(vec!["\n\n".to_string()]),
..LlmConfig::default()
};
match to_stop_sequence(&config) {
Some(liter_llm::StopSequence::Multiple(sequences)) => {
assert_eq!(sequences, vec!["\n\n".to_string()]);
}
other => panic!("expected StopSequence::Multiple, got {other:?}"),
}
}
#[test]
fn test_to_stop_sequence_maps_multiple_entries_in_order() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
stop: Some(vec!["\n\n".to_string(), "[END]".to_string()]),
..LlmConfig::default()
};
match to_stop_sequence(&config) {
Some(liter_llm::StopSequence::Multiple(sequences)) => {
assert_eq!(sequences, vec!["\n\n".to_string(), "[END]".to_string()]);
}
other => panic!("expected StopSequence::Multiple, got {other:?}"),
}
}
#[test]
fn test_build_client_config_rejects_invalid_top_p() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
top_p: Some(1.5),
..LlmConfig::default()
};
match build_client_config(&config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(message.contains("top_p"), "{message}");
}
other => panic!("expected a Validation error, got {other:?}"),
}
}
#[test]
fn test_build_client_config_rejects_invalid_presence_penalty() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
presence_penalty: Some(-3.0),
..LlmConfig::default()
};
match build_client_config(&config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(message.contains("presence_penalty"), "{message}");
}
other => panic!("expected a Validation error, got {other:?}"),
}
}
#[test]
fn test_build_client_config_rejects_invalid_frequency_penalty() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
frequency_penalty: Some(3.0),
..LlmConfig::default()
};
match build_client_config(&config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(message.contains("frequency_penalty"), "{message}");
}
other => panic!("expected a Validation error, got {other:?}"),
}
}
#[test]
fn test_build_client_config_accepts_valid_sampling_fields() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
top_p: Some(0.9),
stop: Some(vec!["\n\n".to_string()]),
seed: Some(42),
presence_penalty: Some(0.5),
frequency_penalty: Some(-0.5),
..LlmConfig::default()
};
assert!(build_client_config(&config).is_ok());
}
#[test]
fn test_build_client_config_attaches_vertex_adc_credential_provider() {
let config = LlmConfig {
model: "vertex_ai/gemini-1.5-pro".to_string(),
credential_provider: Some(Box::new(CredentialProviderConfig::VertexAdc { scope: None })),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
assert!(client_config.credential_provider.is_some());
let rendered = format!("{client_config:?}");
assert!(
rendered.contains(r#"credential_provider: Some("[configured]")"#),
"{rendered}"
);
}
#[test]
fn test_build_client_config_attaches_azure_ad_credential_provider_without_leaking_secret() {
let config = LlmConfig {
model: "azure/gpt-4o".to_string(),
credential_provider: Some(Box::new(CredentialProviderConfig::AzureAd {
tenant_id: "tenant-123".to_string(),
client_id: "client-456".to_string(),
client_secret: "super-secret-azure-value".to_string(),
scope: None,
})),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
let rendered = format!("{client_config:?}");
assert!(
!rendered.contains("super-secret-azure-value"),
"liter-llm ClientConfig Debug leaked the Azure client secret: {rendered}"
);
assert!(
rendered.contains(r#"credential_provider: Some("[configured]")"#),
"{rendered}"
);
}
#[test]
fn test_build_client_config_rejects_missing_vertex_oauth2_key_file() {
let config = LlmConfig {
model: "vertex_ai/gemini-1.5-pro".to_string(),
credential_provider: Some(Box::new(CredentialProviderConfig::VertexOauth2 {
service_account_key_file: "/nonexistent/xberg-test-vertex-key.json".to_string(),
scope: None,
})),
..LlmConfig::default()
};
match build_client_config(&config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(
message.contains("/nonexistent/xberg-test-vertex-key.json"),
"error should name the file: {message}"
);
assert!(
message.contains("Vertex OAuth2"),
"error should name the auth mode: {message}"
);
}
other => panic!("expected a Validation error, got: {other:?}"),
}
}
#[test]
fn test_build_client_config_attaches_vertex_oauth2_credential_provider_from_key_file() {
let path = std::env::temp_dir().join(format!(
"xberg-test-vertex-oauth2-key-{}-{:?}.json",
std::process::id(),
std::thread::current().id()
));
let pem_label = "PRIVATE KEY";
let fixture_json = serde_json::json!({
"client_email": "xberg-test@example.iam.gserviceaccount.com",
"private_key": format!("-----BEGIN {pem_label}-----\nZmFrZS1rZXktbWF0ZXJpYWw=\n-----END {pem_label}-----\n"),
});
std::fs::write(&path, fixture_json.to_string()).expect("write fixture key file");
let config = LlmConfig {
model: "vertex_ai/gemini-1.5-pro".to_string(),
credential_provider: Some(Box::new(CredentialProviderConfig::VertexOauth2 {
service_account_key_file: path.to_string_lossy().into_owned(),
scope: Some("https://www.googleapis.com/auth/cloud-platform".to_string()),
})),
..LlmConfig::default()
};
let result = build_client_config(&config);
let _ = std::fs::remove_file(&path);
let client_config = result.expect("build client config");
assert!(client_config.credential_provider.is_some());
}
#[test]
fn test_build_client_config_attaches_bedrock_web_identity_credential_provider() {
let config = LlmConfig {
model: "bedrock/anthropic.claude-3-sonnet-20240229-v1:0".to_string(),
credential_provider: Some(Box::new(CredentialProviderConfig::BedrockWebIdentity {
role_arn: "arn:aws:iam::123456789012:role/xberg-bedrock".to_string(),
token_file: "/var/run/secrets/eks.amazonaws.com/serviceaccount/token".to_string(),
session_name: None,
region: None,
})),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
assert!(client_config.credential_provider.is_some());
}
#[test]
fn test_build_client_config_leaves_credential_provider_unset_when_absent() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
api_key: Some("test-key".to_string()),
..LlmConfig::default()
};
let client_config = build_client_config(&config).expect("build client config");
assert!(client_config.credential_provider.is_none());
}
fn base_request() -> liter_llm::ChatCompletionRequest {
liter_llm::ChatCompletionRequest {
model: "openai/gpt-4o".to_string(),
messages: vec![liter_llm::Message::User(liter_llm::UserMessage {
content: liter_llm::UserContent::Text("hello".to_string()),
name: None,
})],
..Default::default()
}
}
#[test]
fn should_forward_stop_sequences_to_the_request() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
stop: Some(vec!["\n\n".to_string(), "[END]".to_string()]),
..LlmConfig::default()
};
let mut request = base_request();
apply_request_time_params(&mut request, &config).expect("apply_request_time_params should succeed");
assert_eq!(
request.stop,
Some(liter_llm::StopSequence::Multiple(vec![
"\n\n".to_string(),
"[END]".to_string()
]))
);
}
#[test]
fn should_leave_stop_none_when_config_stop_is_unset() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
..LlmConfig::default()
};
let mut request = base_request();
apply_request_time_params(&mut request, &config).expect("apply_request_time_params should succeed");
assert_eq!(request.stop, None);
}
#[test]
fn should_forward_top_p_seed_and_penalties_to_the_request() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
top_p: Some(0.9),
seed: Some(42),
presence_penalty: Some(0.5),
frequency_penalty: Some(-0.5),
..LlmConfig::default()
};
let mut request = base_request();
apply_request_time_params(&mut request, &config).expect("apply_request_time_params should succeed");
assert_eq!(request.top_p, Some(0.9));
assert_eq!(request.seed, Some(42));
assert_eq!(request.presence_penalty, Some(0.5));
assert_eq!(request.frequency_penalty, Some(-0.5));
}
#[test]
fn should_forward_temperature_max_tokens_reasoning_effort_and_extra_body_to_the_request() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
temperature: Some(0.7),
max_tokens: Some(1024),
reasoning_effort: Some("high".to_string()),
extra_body: Some(serde_json::json!({"safety_settings": {"harassment": "block_none"}})),
..LlmConfig::default()
};
let mut request = base_request();
apply_request_time_params(&mut request, &config).expect("apply_request_time_params should succeed");
assert_eq!(request.temperature, Some(0.7));
assert_eq!(request.max_tokens, Some(1024));
assert!(matches!(
request.reasoning_effort,
Some(liter_llm::ReasoningEffort::High)
));
assert_eq!(
request.extra_body,
Some(serde_json::json!({"safety_settings": {"harassment": "block_none"}}))
);
}
#[test]
fn should_return_validation_error_for_invalid_reasoning_effort() {
let config = LlmConfig {
model: "openai/gpt-4o".to_string(),
reasoning_effort: Some("maximum".to_string()),
stop: Some(vec!["\n\n".to_string()]),
temperature: Some(0.7),
..LlmConfig::default()
};
let mut request = base_request();
match apply_request_time_params(&mut request, &config) {
Err(crate::XbergError::Validation { message, .. }) => {
assert!(message.contains("reasoning_effort"), "{message}");
assert!(message.contains("maximum"), "{message}");
}
other => panic!("expected a Validation error, got {other:?}"),
}
assert_eq!(request.stop, None, "a rejected config must not half-apply");
assert_eq!(request.temperature, None, "a rejected config must not half-apply");
}
#[test]
fn should_not_touch_model_or_messages_fields() {
let config = LlmConfig {
model: "anthropic/claude-sonnet-4-20250514".to_string(),
temperature: Some(0.3),
..LlmConfig::default()
};
let mut request = base_request();
let original_model = request.model.clone();
let original_messages_len = request.messages.len();
apply_request_time_params(&mut request, &config).expect("apply_request_time_params should succeed");
assert_eq!(request.model, original_model, "model must stay call-site controlled");
assert_eq!(
request.messages.len(),
original_messages_len,
"messages must stay call-site controlled"
);
}
}