use std::collections::HashMap;
use serde::{Deserialize, Serialize};
const REDACTED: &str = "[redacted]";
const TOP_P_MIN: f64 = 0.0;
const TOP_P_MAX: f64 = 1.0;
const PENALTY_MIN: f64 = -2.0;
const PENALTY_MAX: f64 = 2.0;
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct LlmConfig {
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_retries: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub top_p: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub stop: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub seed: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub presence_penalty: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub frequency_penalty: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub reasoning_effort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub extra_body: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub load_env: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub providers: Option<Vec<LlmProviderConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub cache: Option<Box<LlmCacheConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub budget: Option<Box<LlmBudgetConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub rate_limit: Option<Box<LlmRateLimitConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub cost_tracking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub tracing: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub cooldown_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub health_check_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub bedrock: Option<Box<BedrockConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub credential_provider: Option<Box<CredentialProviderConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub max_concurrency: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "alef-meta", alef(since = "1.2.3"))]
pub max_response_bytes: Option<usize>,
}
impl LlmConfig {
pub fn validate(&self) -> crate::Result<()> {
self.validate_sampling_parameters()?;
validate_response_byte_cap(self.max_response_bytes)?;
#[cfg(target_arch = "wasm32")]
validate_wasm_credential_provider(self.credential_provider.as_deref())?;
Ok(())
}
fn validate_sampling_parameters(&self) -> crate::Result<()> {
if let Some(top_p) = self.top_p {
validate_sampling_range("top_p", top_p, TOP_P_MIN, TOP_P_MAX)?;
}
if let Some(presence_penalty) = self.presence_penalty {
validate_sampling_range("presence_penalty", presence_penalty, PENALTY_MIN, PENALTY_MAX)?;
}
if let Some(frequency_penalty) = self.frequency_penalty {
validate_sampling_range("frequency_penalty", frequency_penalty, PENALTY_MIN, PENALTY_MAX)?;
}
Ok(())
}
#[cfg(test)]
pub(crate) fn validate_for_wasm_target(&self) -> crate::Result<()> {
self.validate_sampling_parameters()?;
validate_response_byte_cap(self.max_response_bytes)?;
validate_wasm_credential_provider(self.credential_provider.as_deref())
}
}
fn validate_response_byte_cap(limit: Option<usize>) -> crate::Result<()> {
if limit == Some(0) {
return Err(crate::XbergError::Validation {
message: "Invalid LLM max_response_bytes 0: must be nonzero".to_string(),
source: None,
});
}
Ok(())
}
#[cfg(any(target_arch = "wasm32", test))]
const WASM_CREDENTIAL_PROVIDER_ERROR: &str =
"credential_provider is not supported on wasm32 targets; use api_key or browser-compatible authentication";
#[cfg(any(target_arch = "wasm32", test))]
fn validate_wasm_credential_provider(provider: Option<&CredentialProviderConfig>) -> crate::Result<()> {
if provider.is_some() {
return Err(crate::XbergError::validation(
WASM_CREDENTIAL_PROVIDER_ERROR.to_string(),
));
}
Ok(())
}
fn validate_sampling_range(field_name: &str, value: f64, min: f64, max: f64) -> crate::Result<()> {
if (min..=max).contains(&value) {
Ok(())
} else {
Err(crate::XbergError::Validation {
message: format!("Invalid LLM {field_name} {value}: expected a value between {min} and {max}"),
source: None,
})
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub enum CredentialProviderConfig {
AzureAd {
tenant_id: String,
client_id: String,
client_secret: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
scope: Option<String>,
},
VertexOauth2 {
service_account_key_file: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
scope: Option<String>,
},
VertexAdc {
#[serde(default, skip_serializing_if = "Option::is_none")]
scope: Option<String>,
},
BedrockWebIdentity {
role_arn: String,
token_file: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
region: Option<String>,
},
}
impl std::fmt::Debug for CredentialProviderConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AzureAd {
tenant_id,
client_id,
scope,
client_secret: _,
} => f
.debug_struct("AzureAd")
.field("tenant_id", tenant_id)
.field("client_id", client_id)
.field("client_secret", &REDACTED)
.field("scope", scope)
.finish(),
Self::VertexOauth2 {
service_account_key_file,
scope,
} => f
.debug_struct("VertexOauth2")
.field("service_account_key_file", service_account_key_file)
.field("scope", scope)
.finish(),
Self::VertexAdc { scope } => f.debug_struct("VertexAdc").field("scope", scope).finish(),
Self::BedrockWebIdentity {
role_arn,
token_file,
session_name,
region,
} => f
.debug_struct("BedrockWebIdentity")
.field("role_arn", role_arn)
.field("token_file", token_file)
.field("session_name", session_name)
.field("region", region)
.finish(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub struct LlmProviderConfig {
pub name: String,
pub base_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_header: Option<String>,
#[serde(default)]
pub model_prefixes: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub struct LlmCacheConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_entries: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_config: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub struct LlmBudgetConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub global_limit: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_limits: Option<HashMap<String, f64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enforcement: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub struct LlmRateLimitConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rpm: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tpm: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub window_seconds: Option<u64>,
}
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "alef-meta", alef(since = "1.1.0"))]
pub struct BedrockConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cross_region_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret_access_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_token: Option<String>,
}
impl std::fmt::Debug for LlmConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let redacted_headers: Option<Vec<(&str, &str)>> = self
.headers
.as_ref()
.map(|headers| headers.keys().map(|name| (name.as_str(), REDACTED)).collect());
f.debug_struct("LlmConfig")
.field("model", &self.model)
.field("api_key", &self.api_key.as_ref().map(|_| REDACTED))
.field("base_url", &self.base_url)
.field("timeout_secs", &self.timeout_secs)
.field("max_retries", &self.max_retries)
.field("temperature", &self.temperature)
.field("max_tokens", &self.max_tokens)
.field("top_p", &self.top_p)
.field("stop", &self.stop)
.field("seed", &self.seed)
.field("presence_penalty", &self.presence_penalty)
.field("frequency_penalty", &self.frequency_penalty)
.field("reasoning_effort", &self.reasoning_effort)
.field("extra_body", &self.extra_body)
.field("load_env", &self.load_env)
.field("headers", &redacted_headers)
.field("providers", &self.providers)
.field("cache", &self.cache)
.field("budget", &self.budget)
.field("rate_limit", &self.rate_limit)
.field("cost_tracking", &self.cost_tracking)
.field("tracing", &self.tracing)
.field("cooldown_secs", &self.cooldown_secs)
.field("health_check_secs", &self.health_check_secs)
.field("bedrock", &self.bedrock)
.field("credential_provider", &self.credential_provider)
.field("max_concurrency", &self.max_concurrency)
.field("max_response_bytes", &self.max_response_bytes)
.finish()
}
}
impl std::fmt::Debug for BedrockConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BedrockConfig")
.field("region", &self.region)
.field("cross_region_prefix", &self.cross_region_prefix)
.field("access_key_id", &self.access_key_id.as_ref().map(|_| REDACTED))
.field("secret_access_key", &self.secret_access_key.as_ref().map(|_| REDACTED))
.field("session_token", &self.session_token.as_ref().map(|_| REDACTED))
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StructuredExtractionConfig {
pub schema: serde_json::Value,
#[serde(default = "StructuredExtractionConfig::default_schema_name")]
pub schema_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_description: Option<String>,
#[serde(default)]
pub strict: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
pub llm: LlmConfig,
}
impl StructuredExtractionConfig {
pub fn default_schema_name() -> String {
"extraction".to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum CallMode {
#[default]
TextOnly,
VisionOnly,
TextPlusVision,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum MergeMode {
#[default]
ObjectMerge,
ArrayConcat,
ObjectFirst,
}
#[cfg(test)]
mod tests;