Skip to main content

liter_llm/
bindings.rs

1//! Binding-friendly API surface for FFI/polyglot bindings.
2//!
3//! This module provides simplified constructors that avoid trait objects and
4//! opaque types, making it straightforward for alef-generated bindings to
5//! create a [`DefaultClient`] from plain scalar values.
6
7use 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/// Binding-friendly options for constructing a client without an unbounded response body.
20#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
21pub struct ClientOptions {
22    /// Provider API key; an empty value omits the authentication header.
23    pub api_key: String,
24    /// Optional provider base URL override.
25    pub base_url: Option<String>,
26    /// Optional request timeout in seconds.
27    pub timeout_secs: Option<u64>,
28    /// Optional retry count for retryable provider failures.
29    pub max_retries: Option<u32>,
30    /// Optional maximum number of response bytes retained in memory.
31    pub max_response_bytes: Option<u64>,
32    /// Optional model or provider hint used for initial provider selection.
33    pub model_hint: Option<String>,
34}
35
36/// Create a client from binding-friendly options.
37///
38/// # Errors
39///
40/// Returns [`LiterLlmError::BadRequest`] for a zero or platform-overflowing
41/// `max_response_bytes`, plus the errors documented by [`create_client`].
42#[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
64/// Create a new LLM client with simple scalar configuration.
65///
66/// This is the primary binding entry-point. All parameters except `api_key`
67/// are optional — omitting them uses the same defaults as
68/// [`ClientConfigBuilder`].
69///
70/// # Errors
71///
72/// Returns [`LiterLlmError`] if the underlying HTTP client cannot be
73/// constructed, or if the resolved provider configuration is invalid.
74pub 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
97/// Create a new LLM client from a JSON string.
98///
99/// The JSON object accepts the same fields as `liter-llm.toml` (snake_case).
100/// Middleware keys (`cache`, `budget`, `cooldown_secs`, `rate_limit`,
101/// `in_flight_limit`, `health_check_secs`, `cost_tracking`, `tracing`) are
102/// applied: the returned client routes the `LlmClient` methods (`chat`,
103/// `chat_stream`, `embed`, `list_models`, `image_generate`, `speech`,
104/// `transcribe`, `moderate`, `rerank`, `search`, `ocr`) through the managed
105/// Tower stack. File, batch and response operations bypass it. Each `providers`
106/// entry is registered process-wide via [`register_custom_provider`](crate::register_custom_provider).
107///
108/// # Errors
109///
110/// Returns [`LiterLlmError::BadRequest`] if `json` is not valid JSON, contains
111/// unknown fields, or sets middleware keys in a build without the `tower` and
112/// `native-http` features. Provider registration failures are propagated.
113pub 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/// Fail loudly instead of silently dropping middleware keys this build cannot apply.
147#[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}