use std::time::Duration;
#[cfg(all(feature = "native-http", feature = "tower"))]
use std::sync::Arc;
use crate::DefaultClient;
#[cfg(all(feature = "native-http", feature = "tower"))]
use crate::client::managed::ManagedClient;
use crate::client::{ClientConfigBuilder, FileConfig, config_file::FileProviderConfig};
use crate::error::{LiterLlmError, Result};
use crate::provider::custom::register_custom_provider;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct ClientOptions {
pub api_key: String,
pub base_url: Option<String>,
pub timeout_secs: Option<u64>,
pub max_retries: Option<u32>,
pub max_response_bytes: Option<u64>,
pub model_hint: Option<String>,
}
#[cfg(feature = "native-http")]
pub fn create_client_with_options(options: ClientOptions) -> Result<DefaultClient> {
let mut builder = ClientConfigBuilder::new(options.api_key);
if let Some(url) = options.base_url {
builder = builder.base_url(url);
}
if let Some(secs) = options.timeout_secs {
builder = builder.timeout(Duration::from_secs(secs));
}
if let Some(retries) = options.max_retries {
builder = builder.max_retries(retries);
}
if let Some(limit) = options.max_response_bytes {
let limit = usize::try_from(limit).map_err(|_| LiterLlmError::BadRequest {
message: "max_response_bytes exceeds the platform limit".into(),
status: 400,
})?;
builder = builder.max_response_bytes(limit)?;
}
DefaultClient::new(builder.build(), options.model_hint.as_deref())
}
pub fn create_client(
api_key: String,
base_url: Option<String>,
timeout_secs: Option<u64>,
max_retries: Option<u32>,
model_hint: Option<String>,
) -> Result<DefaultClient> {
let mut builder = ClientConfigBuilder::new(api_key);
if let Some(url) = base_url {
builder = builder.base_url(url);
}
if let Some(secs) = timeout_secs {
builder = builder.timeout(Duration::from_secs(secs));
}
if let Some(retries) = max_retries {
builder = builder.max_retries(retries);
}
let config = builder.build();
DefaultClient::new(config, model_hint.as_deref())
}
pub fn create_client_from_json(json: &str) -> Result<DefaultClient> {
let file_config: FileConfig = serde_json::from_str(json).map_err(|error| LiterLlmError::BadRequest {
message: format!("invalid client config JSON: {error}"),
status: 400,
})?;
#[cfg(not(all(feature = "native-http", feature = "tower")))]
reject_unsupported_middleware(&file_config)?;
let model_hint = file_config.model_hint.clone();
let providers: Vec<_> = file_config
.providers()
.iter()
.map(FileProviderConfig::to_custom_provider_config)
.collect();
let config = file_config.into_builder().build();
let client = DefaultClient::new(config.clone(), model_hint.as_deref())?;
for provider in providers {
register_custom_provider(provider)?;
}
#[cfg(all(feature = "native-http", feature = "tower"))]
{
let managed = ManagedClient::from_client(client.clone(), &config)?;
if managed.has_middleware() {
return Ok(client.with_managed(Arc::new(managed)));
}
}
Ok(client)
}
#[cfg(not(all(feature = "native-http", feature = "tower")))]
fn reject_unsupported_middleware(file_config: &FileConfig) -> Result<()> {
let set: Vec<&str> = [
("cache", file_config.cache.is_some()),
("budget", file_config.budget.is_some()),
("cooldown_secs", file_config.cooldown_secs.is_some()),
("rate_limit", file_config.rate_limit.is_some()),
("in_flight_limit", file_config.in_flight_limit.is_some()),
("health_check_secs", file_config.health_check_secs.is_some()),
("cost_tracking", file_config.cost_tracking == Some(true)),
("tracing", file_config.tracing == Some(true)),
]
.into_iter()
.filter_map(|(key, present)| present.then_some(key))
.collect();
if set.is_empty() {
return Ok(());
}
Err(LiterLlmError::BadRequest {
message: format!(
"client config keys [{}] require the `tower` and `native-http` features, which this build lacks",
set.join(", ")
),
status: 400,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_client_with_defaults_succeeds() {
assert!(create_client("sk-test".to_owned(), None, None, None, None).is_ok());
}
#[test]
fn create_client_with_all_options_succeeds() {
assert!(
create_client(
"sk-test".to_owned(),
Some("https://api.openai.com/v1".to_owned()),
Some(30),
Some(5),
Some("openai/gpt-4o".to_owned()),
)
.is_ok()
);
}
#[test]
fn create_client_from_json_minimal_succeeds() {
assert!(create_client_from_json(r#"{"api_key": "sk-test"}"#).is_ok());
}
#[test]
fn create_client_from_json_accepts_response_body_limit() {
assert!(create_client_from_json(r#"{"api_key":"sk-test","max_response_bytes":33554432}"#).is_ok());
}
#[test]
fn create_client_from_json_rejects_zero_response_body_limit() {
let error = create_client_from_json(r#"{"api_key":"sk-test","max_response_bytes":0}"#)
.err()
.expect("zero must be rejected");
assert!(matches!(error, LiterLlmError::BadRequest { status: 400, .. }));
}
#[cfg(feature = "native-http")]
#[test]
fn create_client_with_options_accepts_response_body_limit() {
let client = create_client_with_options(ClientOptions {
api_key: "sk-test".into(),
max_response_bytes: Some(32 * 1024 * 1024),
..ClientOptions::default()
});
assert!(client.is_ok());
}
#[cfg(not(all(feature = "native-http", feature = "tower")))]
#[test]
fn create_client_from_json_rejects_middleware_keys_without_tower() {
let err = create_client_from_json(
r#"{"api_key": "sk-test", "cache": {"max_entries": 8}, "budget": {"global_limit": 1.0}, "tracing": false}"#,
)
.err()
.expect("middleware keys must not be silently dropped");
match err {
LiterLlmError::BadRequest { message, status } => {
assert_eq!(status, 400);
assert!(message.contains("cache, budget"), "message = {message}");
assert!(
!message.contains("tracing"),
"tracing=false is not a middleware request"
);
}
other => panic!("expected BadRequest, got {other:?}"),
}
}
#[test]
fn create_client_from_json_invalid_json_returns_error() {
let result = create_client_from_json("not json {{{");
let err = result.err().expect("invalid JSON should return an error");
assert!(matches!(err, LiterLlmError::BadRequest { .. }));
}
}