1use std::time::Duration;
8
9#[cfg(all(feature = "native-http", feature = "tower"))]
10use std::sync::Arc;
11
12use crate::DefaultClient;
13#[cfg(all(feature = "native-http", feature = "tower"))]
14use crate::client::managed::ManagedClient;
15use crate::client::{ClientConfigBuilder, FileConfig, config_file::FileProviderConfig};
16use crate::error::{LiterLlmError, Result};
17use crate::provider::custom::register_custom_provider;
18
19#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
21pub struct ClientOptions {
22 pub api_key: String,
24 pub base_url: Option<String>,
26 pub timeout_secs: Option<u64>,
28 pub max_retries: Option<u32>,
30 pub max_response_bytes: Option<u64>,
32 pub model_hint: Option<String>,
34}
35
36#[cfg(feature = "native-http")]
43pub fn create_client_with_options(options: ClientOptions) -> Result<DefaultClient> {
44 let mut builder = ClientConfigBuilder::new(options.api_key);
45 if let Some(url) = options.base_url {
46 builder = builder.base_url(url);
47 }
48 if let Some(secs) = options.timeout_secs {
49 builder = builder.timeout(Duration::from_secs(secs));
50 }
51 if let Some(retries) = options.max_retries {
52 builder = builder.max_retries(retries);
53 }
54 if let Some(limit) = options.max_response_bytes {
55 let limit = usize::try_from(limit).map_err(|_| LiterLlmError::BadRequest {
56 message: "max_response_bytes exceeds the platform limit".into(),
57 status: 400,
58 })?;
59 builder = builder.max_response_bytes(limit)?;
60 }
61 DefaultClient::new(builder.build(), options.model_hint.as_deref())
62}
63
64pub fn create_client(
75 api_key: String,
76 base_url: Option<String>,
77 timeout_secs: Option<u64>,
78 max_retries: Option<u32>,
79 model_hint: Option<String>,
80) -> Result<DefaultClient> {
81 let mut builder = ClientConfigBuilder::new(api_key);
82
83 if let Some(url) = base_url {
84 builder = builder.base_url(url);
85 }
86 if let Some(secs) = timeout_secs {
87 builder = builder.timeout(Duration::from_secs(secs));
88 }
89 if let Some(retries) = max_retries {
90 builder = builder.max_retries(retries);
91 }
92
93 let config = builder.build();
94 DefaultClient::new(config, model_hint.as_deref())
95}
96
97pub fn create_client_from_json(json: &str) -> Result<DefaultClient> {
114 let file_config: FileConfig = serde_json::from_str(json).map_err(|error| LiterLlmError::BadRequest {
115 message: format!("invalid client config JSON: {error}"),
116 status: 400,
117 })?;
118
119 #[cfg(not(all(feature = "native-http", feature = "tower")))]
120 reject_unsupported_middleware(&file_config)?;
121
122 let model_hint = file_config.model_hint.clone();
123 let providers: Vec<_> = file_config
124 .providers()
125 .iter()
126 .map(FileProviderConfig::to_custom_provider_config)
127 .collect();
128 let config = file_config.into_builder().build();
129 let client = DefaultClient::new(config.clone(), model_hint.as_deref())?;
130
131 for provider in providers {
132 register_custom_provider(provider)?;
133 }
134
135 #[cfg(all(feature = "native-http", feature = "tower"))]
136 {
137 let managed = ManagedClient::from_client(client.clone(), &config)?;
138 if managed.has_middleware() {
139 return Ok(client.with_managed(Arc::new(managed)));
140 }
141 }
142
143 Ok(client)
144}
145
146#[cfg(not(all(feature = "native-http", feature = "tower")))]
148fn reject_unsupported_middleware(file_config: &FileConfig) -> Result<()> {
149 let set: Vec<&str> = [
150 ("cache", file_config.cache.is_some()),
151 ("budget", file_config.budget.is_some()),
152 ("cooldown_secs", file_config.cooldown_secs.is_some()),
153 ("rate_limit", file_config.rate_limit.is_some()),
154 ("in_flight_limit", file_config.in_flight_limit.is_some()),
155 ("health_check_secs", file_config.health_check_secs.is_some()),
156 ("cost_tracking", file_config.cost_tracking == Some(true)),
157 ("tracing", file_config.tracing == Some(true)),
158 ]
159 .into_iter()
160 .filter_map(|(key, present)| present.then_some(key))
161 .collect();
162
163 if set.is_empty() {
164 return Ok(());
165 }
166 Err(LiterLlmError::BadRequest {
167 message: format!(
168 "client config keys [{}] require the `tower` and `native-http` features, which this build lacks",
169 set.join(", ")
170 ),
171 status: 400,
172 })
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn create_client_with_defaults_succeeds() {
181 assert!(create_client("sk-test".to_owned(), None, None, None, None).is_ok());
182 }
183
184 #[test]
185 fn create_client_with_all_options_succeeds() {
186 assert!(
187 create_client(
188 "sk-test".to_owned(),
189 Some("https://api.openai.com/v1".to_owned()),
190 Some(30),
191 Some(5),
192 Some("openai/gpt-4o".to_owned()),
193 )
194 .is_ok()
195 );
196 }
197
198 #[test]
199 fn create_client_from_json_minimal_succeeds() {
200 assert!(create_client_from_json(r#"{"api_key": "sk-test"}"#).is_ok());
201 }
202
203 #[test]
204 fn create_client_from_json_accepts_response_body_limit() {
205 assert!(create_client_from_json(r#"{"api_key":"sk-test","max_response_bytes":33554432}"#).is_ok());
206 }
207
208 #[test]
209 fn create_client_from_json_rejects_zero_response_body_limit() {
210 let error = create_client_from_json(r#"{"api_key":"sk-test","max_response_bytes":0}"#)
211 .err()
212 .expect("zero must be rejected");
213 assert!(matches!(error, LiterLlmError::BadRequest { status: 400, .. }));
214 }
215
216 #[cfg(feature = "native-http")]
217 #[test]
218 fn create_client_with_options_accepts_response_body_limit() {
219 let client = create_client_with_options(ClientOptions {
220 api_key: "sk-test".into(),
221 max_response_bytes: Some(32 * 1024 * 1024),
222 ..ClientOptions::default()
223 });
224 assert!(client.is_ok());
225 }
226
227 #[cfg(not(all(feature = "native-http", feature = "tower")))]
228 #[test]
229 fn create_client_from_json_rejects_middleware_keys_without_tower() {
230 let err = create_client_from_json(
231 r#"{"api_key": "sk-test", "cache": {"max_entries": 8}, "budget": {"global_limit": 1.0}, "tracing": false}"#,
232 )
233 .err()
234 .expect("middleware keys must not be silently dropped");
235 match err {
236 LiterLlmError::BadRequest { message, status } => {
237 assert_eq!(status, 400);
238 assert!(message.contains("cache, budget"), "message = {message}");
239 assert!(
240 !message.contains("tracing"),
241 "tracing=false is not a middleware request"
242 );
243 }
244 other => panic!("expected BadRequest, got {other:?}"),
245 }
246 }
247
248 #[test]
249 fn create_client_from_json_invalid_json_returns_error() {
250 let result = create_client_from_json("not json {{{");
251 let err = result.err().expect("invalid JSON should return an error");
252 assert!(matches!(err, LiterLlmError::BadRequest { .. }));
253 }
254}