1pub mod builder;
3pub mod config;
5#[allow(missing_docs)]
7pub mod config_file;
8pub mod llm_config;
10#[cfg(all(feature = "native-http", feature = "tower"))]
12pub mod managed;
13
14use std::future::Future;
15use std::pin::Pin;
16#[cfg(any(feature = "native-http", feature = "wasm-http"))]
17use std::sync::Arc;
18
19use futures_core::Stream;
20
21use crate::error::{LiterLlmError, Result};
22use crate::types::audio::{CreateSpeechRequest, CreateTranscriptionRequest, TranscriptionResponse};
23use crate::types::batch::{BatchListQuery, BatchListResponse, BatchObject, CreateBatchRequest};
24use crate::types::files::{CreateFileRequest, DeleteResponse, FileListQuery, FileListResponse, FileObject};
25use crate::types::image::{CreateImageRequest, ImagesResponse};
26use crate::types::moderation::{ModerationRequest, ModerationResponse};
27use crate::types::ocr::{OcrRequest, OcrResponse};
28use crate::types::raw::{RawExchange, RawStreamExchange};
29use crate::types::rerank::{RerankRequest, RerankResponse};
30use crate::types::responses::{CreateResponseRequest, ResponseObject, ResponseStreamEvent};
31use crate::types::search::{SearchRequest, SearchResponse};
32use crate::types::{
33 ChatCompletionChunk, ChatCompletionRequest, ChatCompletionResponse, EmbeddingRequest, EmbeddingResponse,
34 ModelsListResponse, Usage,
35};
36
37#[cfg(any(feature = "native-http", feature = "wasm-http"))]
38use crate::auth::Credential;
39#[cfg(any(feature = "native-http", feature = "wasm-http"))]
40use crate::http;
41#[cfg(any(feature = "native-http", feature = "wasm-http"))]
42use crate::provider::{self, OpenAiCompatibleProvider, OpenAiProvider, Provider};
43#[cfg(any(feature = "native-http", feature = "wasm-http"))]
44use secrecy::ExposeSecret;
45
46pub use builder::{ClientBuilder, NoApiKey, NoProvider, WithApiKey, WithProvider};
47pub use config::{ClientConfig, ClientConfigBuilder};
48pub use config_file::FileConfig;
49pub use llm_config::{
50 BedrockConfig, LlmBudgetConfig, LlmCacheConfig, LlmConfig, LlmInFlightLimitConfig, LlmProviderConfig,
51 LlmRateLimitConfig,
52};
53
54use crate::types::batch::BatchStatus;
55use std::time::Duration;
56
57#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
62pub struct WaitForBatchConfig {
63 pub initial_interval_secs: f64,
65 pub max_interval_secs: f64,
67 pub backoff_multiplier: f32,
69 pub timeout_secs: Option<f64>,
71}
72
73impl Default for WaitForBatchConfig {
74 fn default() -> Self {
75 Self {
76 initial_interval_secs: 5.0,
77 max_interval_secs: 60.0,
78 backoff_multiplier: 1.5,
79 timeout_secs: None,
80 }
81 }
82}
83
84#[derive(Debug, thiserror::Error)]
90pub enum BatchWaitError {
91 #[error("batch reached terminal failure state: {status:?}")]
93 Failed {
94 status: BatchStatus,
96 },
97
98 #[error("polling timed out after {timeout_secs:.1}s")]
100 Timeout {
101 timeout_secs: f64,
103 },
104
105 #[error("client error (code {code}): {message}")]
107 Client {
108 message: String,
110 code: u32,
112 },
113}
114
115impl From<LiterLlmError> for BatchWaitError {
116 fn from(err: LiterLlmError) -> Self {
117 Self::Client {
118 code: u32::from(err.status_code()),
119 message: err.to_string(),
120 }
121 }
122}
123
124#[cfg(not(target_arch = "wasm32"))]
126#[cfg_attr(alef, alef(skip))]
127pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
128
129#[cfg(target_arch = "wasm32")]
131#[cfg_attr(alef, alef(skip))]
132pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
133
134#[cfg(not(target_arch = "wasm32"))]
136#[cfg_attr(alef, alef(skip))]
137pub type BoxStream<'a, T> = Pin<Box<dyn Stream<Item = T> + Send + 'a>>;
138
139#[cfg(target_arch = "wasm32")]
141#[cfg_attr(alef, alef(skip))]
142pub type BoxStream<'a, T> = Pin<Box<dyn Stream<Item = T> + 'a>>;
143
144#[cfg(any(feature = "native-http", feature = "wasm-http"))]
158struct PreparedRequest {
159 url: String,
160 provider: Arc<dyn Provider>,
161 body_json: serde_json::Value,
162 body_bytes: bytes::Bytes,
163}
164
165#[cfg(any(feature = "native-http", feature = "wasm-http"))]
170fn str_pair(pair: &(String, String)) -> (&str, &str) {
171 (pair.0.as_str(), pair.1.as_str())
172}
173
174#[cfg(any(feature = "native-http", feature = "wasm-http"))]
175fn provider_auth_header(provider: &dyn Provider, api_key: &str) -> Option<(String, String)> {
176 (!api_key.is_empty())
177 .then(|| provider.auth_header(api_key))
178 .flatten()
179 .map(|(name, value)| (name.into_owned(), value.into_owned()))
180}
181
182#[cfg(any(feature = "native-http", feature = "wasm-http"))]
183#[derive(Default)]
184struct ChatStreamState {
185 usage: Option<Usage>,
186 model: String,
187}
188
189#[cfg(any(feature = "native-http", feature = "wasm-http"))]
190fn attribute_chat_model(provider: &dyn Provider, model: &mut String, fallback_model: &str) {
191 if model.is_empty() && matches!(provider.name(), "google_ai" | "vertex_ai") {
192 tracing::debug!(
193 provider = provider.name(),
194 fallback_model,
195 "Gemini omitted modelVersion; using request or prior stream model context"
196 );
197 *model = fallback_model.to_owned();
198 }
199}
200
201#[cfg(any(feature = "native-http", feature = "wasm-http"))]
202fn parse_chat_stream_event(
203 provider: &dyn Provider,
204 state: &std::sync::Mutex<ChatStreamState>,
205 data: &str,
206) -> Result<Option<ChatCompletionChunk>> {
207 let mut chunk = match provider.parse_stream_event(data)? {
208 Some(chunk) => chunk,
209 None => return Ok(None),
210 };
211 if !matches!(provider.name(), "anthropic" | "google_ai" | "vertex_ai") {
212 return Ok(Some(chunk));
213 }
214 let mut state = state.lock().map_err(|_| LiterLlmError::InternalError {
215 message: "chat stream state lock poisoned".into(),
216 })?;
217 attribute_chat_model(provider, &mut chunk.model, &state.model);
218 if matches!(provider.name(), "google_ai" | "vertex_ai") {
219 state.model.clone_from(&chunk.model);
220 return Ok(Some(chunk));
221 }
222 if let Some(usage) = chunk.usage.as_mut() {
223 let aggregate = state.usage.get_or_insert_with(Usage::default);
224 aggregate.prompt_tokens = aggregate.prompt_tokens.max(usage.prompt_tokens);
225 aggregate.completion_tokens = aggregate.completion_tokens.max(usage.completion_tokens);
226 aggregate.total_tokens = aggregate.prompt_tokens + aggregate.completion_tokens;
227 if usage.prompt_tokens_details.is_some() {
228 aggregate.prompt_tokens_details.clone_from(&usage.prompt_tokens_details);
229 }
230 if usage.completion_tokens_details.is_some() {
231 aggregate
232 .completion_tokens_details
233 .clone_from(&usage.completion_tokens_details);
234 }
235 usage.clone_from(aggregate);
236 }
237 Ok(Some(chunk))
238}
239
240#[cfg(any(feature = "native-http", feature = "wasm-http"))]
249fn requires_terminal_chat_chunk(provider: &dyn Provider) -> bool {
250 matches!(provider.name(), "anthropic" | "google_ai" | "vertex_ai" | "cohere")
251 || matches!(provider.stream_format(), provider::StreamFormat::AwsEventStream)
252}
253
254#[cfg(any(feature = "native-http", feature = "wasm-http"))]
260struct RequireTerminalChunk {
261 inner: BoxStream<'static, Result<ChatCompletionChunk>>,
262 provider: String,
263 finished: bool,
264 errored: bool,
265 ended: bool,
266}
267
268#[cfg(any(feature = "native-http", feature = "wasm-http"))]
269impl RequireTerminalChunk {
270 fn wrap(
271 inner: BoxStream<'static, Result<ChatCompletionChunk>>,
272 provider: String,
273 ) -> BoxStream<'static, Result<ChatCompletionChunk>> {
274 Box::pin(Self {
275 inner,
276 provider,
277 finished: false,
278 errored: false,
279 ended: false,
280 })
281 }
282}
283
284#[cfg(any(feature = "native-http", feature = "wasm-http"))]
285impl Stream for RequireTerminalChunk {
286 type Item = Result<ChatCompletionChunk>;
287
288 fn poll_next(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<Option<Self::Item>> {
289 use std::task::Poll;
290
291 if self.ended {
292 return Poll::Ready(None);
293 }
294 match self.inner.as_mut().poll_next(cx) {
295 Poll::Ready(Some(Ok(chunk))) => {
296 if chunk.choices.iter().any(|choice| choice.finish_reason.is_some()) {
297 self.finished = true;
298 }
299 Poll::Ready(Some(Ok(chunk)))
300 }
301 Poll::Ready(Some(Err(error))) => {
302 self.errored = true;
303 Poll::Ready(Some(Err(error)))
304 }
305 Poll::Ready(None) => {
306 self.ended = true;
307 if self.finished || self.errored {
308 return Poll::Ready(None);
309 }
310 tracing::warn!(
311 provider = %self.provider,
312 "chat stream ended before the provider sent its final event"
313 );
314 Poll::Ready(Some(Err(LiterLlmError::Streaming {
315 message: format!(
316 "{} stream ended before its final event; the response is incomplete",
317 self.provider
318 ),
319 })))
320 }
321 Poll::Pending => Poll::Pending,
322 }
323 }
324}
325
326#[cfg(any(feature = "native-http", feature = "wasm-http"))]
341fn merge_extra_body(body: &mut serde_json::Value) {
342 let Some(obj) = body.as_object_mut() else {
343 return;
344 };
345 let Some(extra_body) = obj.remove("extra_body") else {
346 return;
347 };
348 match extra_body {
349 serde_json::Value::Object(extra_fields) => {
350 obj.extend(extra_fields);
351 }
352 other => {
353 tracing::warn!(
354 extra_body_type = json_value_type_name(&other),
355 "ignoring non-object extra_body; it cannot be merged into the request body"
356 );
357 }
358 }
359}
360
361#[cfg(any(feature = "native-http", feature = "wasm-http"))]
370fn response_request_body(req: &CreateResponseRequest) -> Result<serde_json::Value> {
371 let mut body = serde_json::to_value(req)?;
372 merge_extra_body(&mut body);
373 Ok(body)
374}
375
376#[cfg(any(feature = "native-http", feature = "wasm-http"))]
380fn response_stream_request_body(mut req: CreateResponseRequest) -> Result<serde_json::Value> {
381 req.stream = Some(true);
382 let mut body = response_request_body(&req)?;
383 if let Some(obj) = body.as_object_mut() {
387 obj.insert("stream".into(), serde_json::Value::Bool(true));
388 }
389 Ok(body)
390}
391
392#[cfg(any(feature = "native-http", feature = "wasm-http"))]
394fn json_value_type_name(value: &serde_json::Value) -> &'static str {
395 match value {
396 serde_json::Value::Null => "null",
397 serde_json::Value::Bool(_) => "boolean",
398 serde_json::Value::Number(_) => "number",
399 serde_json::Value::String(_) => "string",
400 serde_json::Value::Array(_) => "array",
401 serde_json::Value::Object(_) => "object",
402 }
403}
404
405#[cfg(not(target_arch = "wasm32"))]
412#[cfg_attr(alef, alef(skip))]
413pub trait LlmClient: Send + Sync {
414 fn chat(&self, req: ChatCompletionRequest) -> BoxFuture<'_, Result<ChatCompletionResponse>>;
427
428 fn chat_stream(
443 &self,
444 req: ChatCompletionRequest,
445 ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ChatCompletionChunk>>>>;
446
447 fn embed(&self, req: EmbeddingRequest) -> BoxFuture<'_, Result<EmbeddingResponse>>;
456
457 fn list_models(&self) -> BoxFuture<'_, Result<ModelsListResponse>>;
466
467 fn image_generate(&self, req: CreateImageRequest) -> BoxFuture<'_, Result<ImagesResponse>>;
477
478 fn speech(&self, req: CreateSpeechRequest) -> BoxFuture<'_, Result<bytes::Bytes>>;
488
489 fn transcribe(&self, req: CreateTranscriptionRequest) -> BoxFuture<'_, Result<TranscriptionResponse>>;
498
499 fn moderate(&self, req: ModerationRequest) -> BoxFuture<'_, Result<ModerationResponse>>;
508
509 fn rerank(&self, req: RerankRequest) -> BoxFuture<'_, Result<RerankResponse>>;
518
519 fn search(&self, req: SearchRequest) -> BoxFuture<'_, Result<SearchResponse>>;
528
529 fn ocr(&self, req: OcrRequest) -> BoxFuture<'_, Result<OcrResponse>>;
538}
539
540#[cfg(target_arch = "wasm32")]
542#[cfg_attr(alef, alef(skip))]
543pub trait LlmClient {
544 fn chat(&self, req: ChatCompletionRequest) -> BoxFuture<'_, Result<ChatCompletionResponse>>;
546
547 fn chat_stream(
549 &self,
550 req: ChatCompletionRequest,
551 ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ChatCompletionChunk>>>>;
552
553 fn embed(&self, req: EmbeddingRequest) -> BoxFuture<'_, Result<EmbeddingResponse>>;
555
556 fn list_models(&self) -> BoxFuture<'_, Result<ModelsListResponse>>;
558
559 fn image_generate(&self, req: CreateImageRequest) -> BoxFuture<'_, Result<ImagesResponse>>;
561
562 fn speech(&self, req: CreateSpeechRequest) -> BoxFuture<'_, Result<bytes::Bytes>>;
564
565 fn transcribe(&self, req: CreateTranscriptionRequest) -> BoxFuture<'_, Result<TranscriptionResponse>>;
567
568 fn moderate(&self, req: ModerationRequest) -> BoxFuture<'_, Result<ModerationResponse>>;
570
571 fn rerank(&self, req: RerankRequest) -> BoxFuture<'_, Result<RerankResponse>>;
573
574 fn search(&self, req: SearchRequest) -> BoxFuture<'_, Result<SearchResponse>>;
576
577 fn ocr(&self, req: OcrRequest) -> BoxFuture<'_, Result<OcrResponse>>;
579}
580
581#[cfg_attr(alef, alef(skip))]
590pub trait LlmClientRaw: LlmClient {
591 fn chat_raw(&self, req: ChatCompletionRequest) -> BoxFuture<'_, Result<RawExchange<ChatCompletionResponse>>>;
597
598 fn chat_stream_raw(
603 &self,
604 req: ChatCompletionRequest,
605 ) -> BoxFuture<'_, Result<RawStreamExchange<BoxStream<'static, Result<ChatCompletionChunk>>>>>;
606
607 fn embed_raw(&self, req: EmbeddingRequest) -> BoxFuture<'_, Result<RawExchange<EmbeddingResponse>>>;
609
610 fn image_generate_raw(&self, req: CreateImageRequest) -> BoxFuture<'_, Result<RawExchange<ImagesResponse>>>;
612
613 fn transcribe_raw(
615 &self,
616 req: CreateTranscriptionRequest,
617 ) -> BoxFuture<'_, Result<RawExchange<TranscriptionResponse>>>;
618
619 fn moderate_raw(&self, req: ModerationRequest) -> BoxFuture<'_, Result<RawExchange<ModerationResponse>>>;
621
622 fn rerank_raw(&self, req: RerankRequest) -> BoxFuture<'_, Result<RawExchange<RerankResponse>>>;
624
625 fn search_raw(&self, req: SearchRequest) -> BoxFuture<'_, Result<RawExchange<SearchResponse>>>;
627
628 fn ocr_raw(&self, req: OcrRequest) -> BoxFuture<'_, Result<RawExchange<OcrResponse>>>;
630}
631
632#[cfg(not(target_arch = "wasm32"))]
634#[cfg_attr(alef, alef(skip))]
635pub trait FileClient: Send + Sync {
636 fn create_file(&self, req: CreateFileRequest) -> BoxFuture<'_, Result<FileObject>>;
638
639 fn retrieve_file(&self, file_id: &str) -> BoxFuture<'_, Result<FileObject>>;
641
642 fn delete_file(&self, file_id: &str) -> BoxFuture<'_, Result<DeleteResponse>>;
644
645 fn list_files(&self, query: Option<FileListQuery>) -> BoxFuture<'_, Result<FileListResponse>>;
647
648 fn file_content(&self, file_id: &str) -> BoxFuture<'_, Result<bytes::Bytes>>;
650}
651
652#[cfg(target_arch = "wasm32")]
654#[cfg_attr(alef, alef(skip))]
655pub trait FileClient {
656 fn create_file(&self, req: CreateFileRequest) -> BoxFuture<'_, Result<FileObject>>;
658
659 fn retrieve_file(&self, file_id: &str) -> BoxFuture<'_, Result<FileObject>>;
661
662 fn delete_file(&self, file_id: &str) -> BoxFuture<'_, Result<DeleteResponse>>;
664
665 fn list_files(&self, query: Option<FileListQuery>) -> BoxFuture<'_, Result<FileListResponse>>;
667
668 fn file_content(&self, file_id: &str) -> BoxFuture<'_, Result<bytes::Bytes>>;
670}
671
672#[cfg(not(target_arch = "wasm32"))]
674#[cfg_attr(alef, alef(skip))]
675pub trait BatchClient: Send + Sync {
676 fn create_batch(&self, req: CreateBatchRequest) -> BoxFuture<'_, Result<BatchObject>>;
678
679 fn retrieve_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>>;
681
682 fn list_batches(&self, query: Option<BatchListQuery>) -> BoxFuture<'_, Result<BatchListResponse>>;
684
685 fn cancel_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>>;
687}
688
689#[cfg(target_arch = "wasm32")]
691#[cfg_attr(alef, alef(skip))]
692pub trait BatchClient {
693 fn create_batch(&self, req: CreateBatchRequest) -> BoxFuture<'_, Result<BatchObject>>;
695
696 fn retrieve_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>>;
698
699 fn list_batches(&self, query: Option<BatchListQuery>) -> BoxFuture<'_, Result<BatchListResponse>>;
701
702 fn cancel_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>>;
704}
705
706#[cfg(not(target_arch = "wasm32"))]
742#[cfg_attr(alef, alef(skip))]
743pub trait ResponseClient: Send + Sync {
744 fn create_response(&self, req: CreateResponseRequest) -> BoxFuture<'_, Result<ResponseObject>>;
746
747 fn create_response_stream(
758 &self,
759 req: CreateResponseRequest,
760 ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ResponseStreamEvent>>>>;
761
762 fn retrieve_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>>;
764
765 fn cancel_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>>;
767}
768
769#[cfg(target_arch = "wasm32")]
779#[cfg_attr(alef, alef(skip))]
780pub trait ResponseClient {
781 fn create_response(&self, req: CreateResponseRequest) -> BoxFuture<'_, Result<ResponseObject>>;
783
784 fn create_response_stream(
795 &self,
796 req: CreateResponseRequest,
797 ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ResponseStreamEvent>>>>;
798
799 fn retrieve_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>>;
801
802 fn cancel_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>>;
804}
805
806#[cfg(any(feature = "native-http", feature = "wasm-http"))]
822#[derive(Clone)]
823pub struct DefaultClient {
824 config: ClientConfig,
825 http: reqwest::Client,
826 provider: Arc<dyn Provider>,
829 cached_auth_header: Option<(String, String)>,
832 cached_extra_headers: Vec<(String, String)>,
835 #[cfg_attr(not(all(feature = "native-http", feature = "tower")), allow(dead_code))]
840 managed: Option<ManagedHandle>,
841}
842
843#[cfg(all(feature = "native-http", feature = "tower"))]
845type ManagedHandle = Arc<managed::ManagedClient>;
846#[cfg(all(
847 any(feature = "native-http", feature = "wasm-http"),
848 not(all(feature = "native-http", feature = "tower"))
849))]
850type ManagedHandle = std::convert::Infallible;
851
852#[cfg(any(feature = "native-http", feature = "wasm-http"))]
853impl DefaultClient {
854 #[cfg(all(feature = "native-http", feature = "tower"))]
856 pub(crate) fn with_managed(mut self, managed: Arc<managed::ManagedClient>) -> Self {
857 self.managed = Some(managed);
858 self
859 }
860
861 fn response_read_options(&self) -> http::request::ResponseReadOptions {
862 http::request::ResponseReadOptions {
863 max_retries: self.config.max_retries,
864 max_response_bytes: self.config.max_response_bytes,
865 }
866 }
867
868 pub fn new(config: ClientConfig, model_hint: Option<&str>) -> Result<Self> {
889 config::validate_response_limit(config.max_response_bytes)?;
890 let provider = build_provider(&config, model_hint);
891 provider.validate()?;
892
893 #[cfg(not(target_arch = "wasm32"))]
894 let mut config = config;
895 #[cfg(not(target_arch = "wasm32"))]
896 if config.load_env
897 && config.api_key.expose_secret().is_empty()
898 && let Some(env_var_name) = provider.env_var()
899 {
900 match std::env::var(env_var_name) {
901 Ok(val) if !val.is_empty() => {
902 config.api_key = secrecy::SecretString::from(val);
903 }
904 _ => {
905 return Err(LiterLlmError::Authentication {
906 message: format!("no API key provided and environment variable {env_var_name} is not set"),
907 status: 401,
908 });
909 }
910 }
911 }
912
913 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
916 if config.credential_provider.is_none()
917 && config.api_key.expose_secret().is_empty()
918 && provider.name() == "vertex_ai"
919 {
920 config.credential_provider = Some(Arc::new(crate::auth::vertex_adc::VertexAdcCredentialProvider::new()));
921 }
922
923 let mut header_map = reqwest::header::HeaderMap::new();
924 for (k, v) in config.headers() {
925 let name =
926 reqwest::header::HeaderName::from_bytes(k.as_bytes()).map_err(|_| LiterLlmError::InvalidHeader {
927 name: k.clone(),
928 reason: "pre-validated header name became invalid".into(),
929 })?;
930 let val = reqwest::header::HeaderValue::from_str(v).map_err(|_| LiterLlmError::InvalidHeader {
931 name: k.clone(),
932 reason: "pre-validated header value became invalid".into(),
933 })?;
934 header_map.insert(name, val);
935 }
936
937 let http = {
938 #[cfg(feature = "native-http")]
939 crate::ensure_crypto_provider();
940 let builder = reqwest::Client::builder().default_headers(header_map);
941 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
942 let builder = crate::provider::configure_outbound_client_builder(builder, config.transport.dns_cache_ttl);
943 #[cfg(not(target_arch = "wasm32"))]
944 let builder = builder.timeout(config.timeout);
945 #[cfg(not(target_arch = "wasm32"))]
946 let builder = config.transport.apply_to_builder(builder);
947 builder.build().map_err(LiterLlmError::from)?
948 };
949
950 let cached_auth_header = provider_auth_header(provider.as_ref(), config.api_key.expose_secret());
951 let cached_extra_headers = provider
952 .extra_headers()
953 .iter()
954 .map(|&(name, value)| (name.to_owned(), value.to_owned()))
955 .collect();
956
957 Ok(Self {
958 config,
959 http,
960 provider,
961 cached_auth_header,
962 cached_extra_headers,
963 managed: None,
964 })
965 }
966
967 fn resolve_provider_for_model(&self, model: &str) -> Arc<dyn Provider> {
973 if self.config.base_url.is_some() {
975 return Arc::clone(&self.provider);
976 }
977 if self.provider.matches_model(model) {
979 return Arc::clone(&self.provider);
980 }
981 if model.starts_with("bedrock/") {
982 return build_bedrock_provider(&self.config);
983 }
984 if let Some(detected) = provider::detect_provider(model) {
985 return Arc::from(detected);
986 }
987 Arc::clone(&self.provider)
988 }
989
990 async fn resolve_auth_header_for_provider(
993 &self,
994 prov: &dyn Provider,
995 ) -> Result<(Option<(String, String)>, Option<Credential>)> {
996 if let Some(ref cp) = self.config.credential_provider {
997 let credential = cp.resolve().await?;
998 let header = match &credential {
999 Credential::BearerToken(token) if token.expose_secret().is_empty() => None,
1000 Credential::BearerToken(token) => {
1001 Some(("Authorization".to_owned(), format!("Bearer {}", token.expose_secret())))
1002 }
1003 Credential::AwsCredentials { .. } => None,
1004 };
1005 Ok((header, Some(credential)))
1006 } else {
1007 Ok((provider_auth_header(prov, self.config.api_key.expose_secret()), None))
1009 }
1010 }
1011
1012 fn all_headers_for_provider(
1019 &self,
1020 prov: &dyn Provider,
1021 credential: Option<&Credential>,
1022 method: &str,
1023 url: &str,
1024 body_json: &serde_json::Value,
1025 body_bytes: &[u8],
1026 ) -> Result<Vec<(String, String)>> {
1027 let mut headers = prov.signing_headers_with_credential(method, url, body_bytes, credential)?;
1028 headers.extend(
1029 prov.extra_headers()
1030 .iter()
1031 .map(|&(name, value)| (name.to_owned(), value.to_owned())),
1032 );
1033 headers.extend(prov.dynamic_headers(body_json));
1034 Ok(headers)
1035 }
1036
1037 fn prepare_request(
1048 &self,
1049 serializable: &impl serde::Serialize,
1050 endpoint_fn: impl FnOnce(&dyn Provider) -> &str,
1051 model: &str,
1052 stream: Option<bool>,
1053 ) -> Result<PreparedRequest> {
1054 if model.is_empty() {
1055 return Err(LiterLlmError::BadRequest {
1056 message: "model must not be empty".into(),
1057 status: 400,
1058 });
1059 }
1060
1061 let prov = self.resolve_provider_for_model(model);
1062 let bare_model = prov.strip_model_prefix(model).to_owned();
1063 let endpoint_path = endpoint_fn(prov.as_ref());
1065 let url = prov.build_url(endpoint_path, &bare_model);
1066
1067 let mut body = serde_json::to_value(serializable)?;
1068 if let Some(obj) = body.as_object_mut() {
1069 obj.insert("model".into(), serde_json::Value::String(bare_model.clone()));
1070 if let Some(s) = stream {
1071 obj.insert("stream".into(), serde_json::Value::Bool(s));
1072 if s && matches!(prov.name(), "openai" | "azure" | "custom") && !obj.contains_key("stream_options") {
1073 obj.insert("stream_options".into(), serde_json::json!({"include_usage": true}));
1074 }
1075 }
1076 if matches!(prov.name(), "openai" | "azure")
1077 && obj.get("max_completion_tokens").is_none()
1078 && obj.get("max_tokens").is_some()
1079 && model_uses_max_completion_tokens(&bare_model)
1080 && let Some(max_tokens) = obj.remove("max_tokens")
1081 {
1082 obj.insert("max_completion_tokens".into(), max_tokens);
1083 }
1084 }
1085 prov.transform_request(&mut body)?;
1086 let provider_wire_carries_stream = body.get("stream").is_some();
1093 merge_extra_body(&mut body);
1094 if provider_wire_carries_stream
1099 && let Some(s) = stream
1100 && let Some(obj) = body.as_object_mut()
1101 {
1102 obj.insert("stream".into(), serde_json::Value::Bool(s));
1103 }
1104
1105 let body_bytes = bytes::Bytes::from(serde_json::to_vec(&body)?);
1107
1108 Ok(PreparedRequest {
1109 url,
1110 provider: prov,
1111 body_json: body,
1112 body_bytes,
1113 })
1114 }
1115
1116 async fn resolve_auth_header(&self) -> Result<(Option<(String, String)>, Option<Credential>)> {
1122 if let Some(ref cp) = self.config.credential_provider {
1123 let credential = cp.resolve().await?;
1124 let header = match &credential {
1125 Credential::BearerToken(token) => {
1126 Some(("Authorization".to_owned(), format!("Bearer {}", token.expose_secret())))
1127 }
1128 Credential::AwsCredentials { .. } => None,
1129 };
1130 Ok((header, Some(credential)))
1131 } else {
1132 Ok((self.cached_auth_header.clone(), None))
1133 }
1134 }
1135
1136 fn all_headers(
1145 &self,
1146 credential: Option<&Credential>,
1147 method: &str,
1148 url: &str,
1149 body_json: &serde_json::Value,
1150 body_bytes: &[u8],
1151 ) -> Result<Vec<(String, String)>> {
1152 let mut headers = self
1153 .provider
1154 .signing_headers_with_credential(method, url, body_bytes, credential)?;
1155 headers.extend(self.cached_extra_headers.iter().cloned());
1156 headers.extend(self.provider.dynamic_headers(body_json));
1157 Ok(headers)
1158 }
1159}
1160
1161#[cfg(any(feature = "native-http", feature = "wasm-http"))]
1162fn model_uses_max_completion_tokens(model: &str) -> bool {
1163 crate::cost::model_info(model).is_some_and(|info| info.supports_reasoning == Some(true))
1164 || model.starts_with("gpt-5")
1165 || model
1166 .strip_prefix('o')
1167 .and_then(|suffix| suffix.chars().next())
1168 .is_some_and(|character| character.is_ascii_digit())
1169}
1170
1171#[cfg(any(feature = "native-http", feature = "wasm-http"))]
1172fn build_provider(config: &ClientConfig, model_hint: Option<&str>) -> Arc<dyn Provider> {
1186 if let Some(ref base_url) = config.base_url {
1187 if let Some(model) = model_hint
1188 && model.starts_with("azure/")
1189 {
1190 return Arc::new(provider::azure::AzureProvider::with_base_url(base_url.clone()));
1191 }
1192 if let Some(model) = model_hint
1193 && model.starts_with("anthropic/")
1194 {
1195 return Arc::new(provider::anthropic::AnthropicProvider::with_base_url(base_url.clone()));
1196 }
1197 if let Some(model) = model_hint
1198 && (model.starts_with("gemini/") || model.starts_with("google_ai/"))
1199 {
1200 return Arc::new(provider::google_ai::GoogleAiProvider::with_base_url(base_url.clone()));
1201 }
1202 if let Some(model) = model_hint
1203 && model.starts_with("vertex_ai/")
1204 {
1205 return Arc::new(provider::vertex::VertexAiProvider::with_base_url(base_url.clone()));
1206 }
1207 if let Some(model) = model_hint
1208 && model.starts_with("bedrock/")
1209 {
1210 return Arc::new(build_bedrock_provider_config(config).with_base_url(base_url.clone()));
1211 }
1212 return Arc::new(OpenAiCompatibleProvider {
1213 name: "custom".into(),
1214 base_url: base_url.clone(),
1215 env_var: None,
1216 model_prefixes: vec![],
1217 hint_provider: model_hint.and_then(provider::hint_provider_name),
1218 });
1219 }
1220
1221 if let Some(model) = model_hint {
1222 if model.starts_with("bedrock/") {
1223 return build_bedrock_provider(config);
1224 }
1225 if let Some(p) = provider::detect_provider(model) {
1226 return Arc::from(p);
1227 }
1228 }
1229
1230 Arc::new(OpenAiProvider)
1231}
1232
1233#[cfg(any(feature = "native-http", feature = "wasm-http"))]
1238fn build_bedrock_provider(config: &ClientConfig) -> Arc<dyn Provider> {
1239 Arc::new(build_bedrock_provider_config(config))
1240}
1241
1242#[cfg(any(feature = "native-http", feature = "wasm-http"))]
1243fn build_bedrock_provider_config(config: &ClientConfig) -> provider::bedrock::BedrockProvider {
1244 provider::bedrock::BedrockProvider::from_config(
1245 config.bedrock_region.clone(),
1246 config.bedrock_cross_region_prefix.clone(),
1247 config.bedrock_access_key_id.clone(),
1248 config.bedrock_secret_access_key.clone(),
1249 config.bedrock_session_token.clone(),
1250 )
1251 .with_dynamic_credentials(config.credential_provider.is_some())
1252}
1253
1254#[cfg(any(feature = "native-http", feature = "wasm-http"))]
1255impl LlmClient for DefaultClient {
1256 fn chat(&self, req: ChatCompletionRequest) -> BoxFuture<'_, Result<ChatCompletionResponse>> {
1257 #[cfg(all(feature = "native-http", feature = "tower"))]
1258 if let Some(managed) = &self.managed {
1259 return managed.chat(req);
1260 }
1261 Box::pin(async move {
1262 let prepared = self.prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(false))?;
1264
1265 let (auth_header, credential) = self
1266 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1267 .await?;
1268 let all_headers = self.all_headers_for_provider(
1269 prepared.provider.as_ref(),
1270 credential.as_ref(),
1271 "POST",
1272 &prepared.url,
1273 &prepared.body_json,
1274 &prepared.body_bytes,
1275 )?;
1276 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1277
1278 let auth = auth_header.as_ref().map(str_pair);
1279 let mut raw = http::request::post_json_raw_bounded(
1280 &self.http,
1281 &prepared.url,
1282 auth,
1283 &extra,
1284 prepared.body_bytes,
1285 self.response_read_options(),
1286 )
1287 .await?;
1288 prepared.provider.transform_response(&mut raw)?;
1289 let mut response = serde_json::from_value::<ChatCompletionResponse>(raw).map_err(LiterLlmError::from)?;
1290 attribute_chat_model(prepared.provider.as_ref(), &mut response.model, &req.model);
1291 Ok(response)
1292 })
1293 }
1294
1295 fn chat_stream(
1296 &self,
1297 req: ChatCompletionRequest,
1298 ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ChatCompletionChunk>>>> {
1299 #[cfg(all(feature = "native-http", feature = "tower"))]
1300 if let Some(managed) = &self.managed {
1301 return managed.chat_stream(req);
1302 }
1303 Box::pin(async move {
1304 let prepared = self.prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(true))?;
1306
1307 let bare_model = prepared.provider.strip_model_prefix(&req.model);
1309 let url = prepared
1310 .provider
1311 .build_stream_url(prepared.provider.chat_completions_path(), bare_model);
1312
1313 let (auth_header, credential) = self
1314 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1315 .await?;
1316 let all_headers = self.all_headers_for_provider(
1317 prepared.provider.as_ref(),
1318 credential.as_ref(),
1319 "POST",
1320 &url,
1321 &prepared.body_json,
1322 &prepared.body_bytes,
1323 )?;
1324 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1325 let auth = auth_header.as_ref().map(str_pair);
1326 let require_terminal = requires_terminal_chat_chunk(prepared.provider.as_ref());
1327 let provider_name = prepared.provider.name().to_owned();
1328
1329 let stream = match prepared.provider.stream_format() {
1330 provider::StreamFormat::Sse => {
1331 let provider = Arc::clone(&prepared.provider);
1332 let state = std::sync::Mutex::new(ChatStreamState {
1333 model: req.model.clone(),
1334 ..Default::default()
1335 });
1336 let parse_event = move |data: &str| parse_chat_stream_event(provider.as_ref(), &state, data);
1337 http::streaming::post_stream_bounded(
1338 &self.http,
1339 http::request::StreamingPost {
1340 url: &url,
1341 auth_header: auth,
1342 extra_headers: &extra,
1343 body: prepared.body_bytes,
1344 },
1345 parse_event,
1346 self.response_read_options(),
1347 )
1348 .await?
1349 }
1350 provider::StreamFormat::AwsEventStream => {
1351 http::eventstream::post_eventstream_bounded(
1352 &self.http,
1353 http::request::StreamingPost {
1354 url: &url,
1355 auth_header: auth,
1356 extra_headers: &extra,
1357 body: prepared.body_bytes,
1358 },
1359 provider::bedrock::parse_bedrock_stream_event,
1360 self.response_read_options(),
1361 )
1362 .await?
1363 }
1364 };
1365 Ok(if require_terminal {
1366 RequireTerminalChunk::wrap(stream, provider_name)
1367 } else {
1368 stream
1369 })
1370 })
1371 }
1372
1373 fn embed(&self, req: EmbeddingRequest) -> BoxFuture<'_, Result<EmbeddingResponse>> {
1374 #[cfg(all(feature = "native-http", feature = "tower"))]
1375 if let Some(managed) = &self.managed {
1376 return managed.embed(req);
1377 }
1378 Box::pin(async move {
1379 let prepared = self.prepare_request(&req, |p| p.embeddings_path(), &req.model, None)?;
1381
1382 let (auth_header, credential) = self
1383 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1384 .await?;
1385 let all_headers = self.all_headers_for_provider(
1386 prepared.provider.as_ref(),
1387 credential.as_ref(),
1388 "POST",
1389 &prepared.url,
1390 &prepared.body_json,
1391 &prepared.body_bytes,
1392 )?;
1393 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1394
1395 let auth = auth_header.as_ref().map(str_pair);
1396 let mut raw = http::request::post_json_raw_bounded(
1397 &self.http,
1398 &prepared.url,
1399 auth,
1400 &extra,
1401 prepared.body_bytes,
1402 self.response_read_options(),
1403 )
1404 .await?;
1405 prepared.provider.transform_response(&mut raw)?;
1406 serde_json::from_value::<EmbeddingResponse>(raw).map_err(LiterLlmError::from)
1407 })
1408 }
1409
1410 fn list_models(&self) -> BoxFuture<'_, Result<ModelsListResponse>> {
1411 #[cfg(all(feature = "native-http", feature = "tower"))]
1412 if let Some(managed) = &self.managed {
1413 return managed.list_models();
1414 }
1415 Box::pin(async move {
1416 let url = self.provider.build_url(self.provider.models_path(), "");
1418 let (auth_header, credential) = self.resolve_auth_header().await?;
1419 let auth = auth_header.as_ref().map(str_pair);
1420 let all_headers = self.all_headers(credential.as_ref(), "GET", &url, &serde_json::Value::Null, &[])?;
1421 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1422
1423 let mut raw = http::request::get_json_raw_bounded(
1424 &self.http,
1425 &url,
1426 auth,
1427 &extra,
1428 self.config.max_retries,
1429 self.config.max_response_bytes,
1430 )
1431 .await?;
1432 self.provider.transform_response(&mut raw)?;
1433 serde_json::from_value::<ModelsListResponse>(raw).map_err(LiterLlmError::from)
1434 })
1435 }
1436
1437 fn image_generate(&self, req: CreateImageRequest) -> BoxFuture<'_, Result<ImagesResponse>> {
1438 #[cfg(all(feature = "native-http", feature = "tower"))]
1439 if let Some(managed) = &self.managed {
1440 return managed.image_generate(req);
1441 }
1442 Box::pin(async move {
1443 let model = req.model.as_deref().unwrap_or_default();
1444 let prepared = self.prepare_request(&req, |p| p.image_generations_path(), model, None)?;
1445
1446 let (auth_header, credential) = self
1447 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1448 .await?;
1449 let all_headers = self.all_headers_for_provider(
1450 prepared.provider.as_ref(),
1451 credential.as_ref(),
1452 "POST",
1453 &prepared.url,
1454 &prepared.body_json,
1455 &prepared.body_bytes,
1456 )?;
1457 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1458
1459 let auth = auth_header.as_ref().map(str_pair);
1460 let mut raw = http::request::post_json_raw_bounded(
1461 &self.http,
1462 &prepared.url,
1463 auth,
1464 &extra,
1465 prepared.body_bytes,
1466 self.response_read_options(),
1467 )
1468 .await?;
1469 prepared.provider.transform_response(&mut raw)?;
1470 serde_json::from_value::<ImagesResponse>(raw).map_err(LiterLlmError::from)
1471 })
1472 }
1473
1474 fn speech(&self, req: CreateSpeechRequest) -> BoxFuture<'_, Result<bytes::Bytes>> {
1475 #[cfg(all(feature = "native-http", feature = "tower"))]
1476 if let Some(managed) = &self.managed {
1477 return managed.speech(req);
1478 }
1479 Box::pin(async move {
1480 let prepared = self.prepare_request(&req, |p| p.audio_speech_path(), &req.model, None)?;
1481
1482 let (auth_header, credential) = self
1483 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1484 .await?;
1485 let all_headers = self.all_headers_for_provider(
1486 prepared.provider.as_ref(),
1487 credential.as_ref(),
1488 "POST",
1489 &prepared.url,
1490 &prepared.body_json,
1491 &prepared.body_bytes,
1492 )?;
1493 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1494
1495 let auth = auth_header.as_ref().map(str_pair);
1496 http::request::post_binary_bounded(
1497 &self.http,
1498 &prepared.url,
1499 auth,
1500 &extra,
1501 prepared.body_bytes,
1502 self.response_read_options(),
1503 )
1504 .await
1505 })
1506 }
1507
1508 fn transcribe(&self, req: CreateTranscriptionRequest) -> BoxFuture<'_, Result<TranscriptionResponse>> {
1509 #[cfg(all(feature = "native-http", feature = "tower"))]
1510 if let Some(managed) = &self.managed {
1511 return managed.transcribe(req);
1512 }
1513 Box::pin(async move {
1514 let prepared = self.prepare_request(&req, |p| p.audio_transcriptions_path(), &req.model, None)?;
1515
1516 let (auth_header, credential) = self
1517 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1518 .await?;
1519 let all_headers = self.all_headers_for_provider(
1520 prepared.provider.as_ref(),
1521 credential.as_ref(),
1522 "POST",
1523 &prepared.url,
1524 &prepared.body_json,
1525 &prepared.body_bytes,
1526 )?;
1527 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1528
1529 let auth = auth_header.as_ref().map(str_pair);
1530 let mut raw = http::request::post_json_raw_bounded(
1531 &self.http,
1532 &prepared.url,
1533 auth,
1534 &extra,
1535 prepared.body_bytes,
1536 self.response_read_options(),
1537 )
1538 .await?;
1539 prepared.provider.transform_response(&mut raw)?;
1540 serde_json::from_value::<TranscriptionResponse>(raw).map_err(LiterLlmError::from)
1541 })
1542 }
1543
1544 fn moderate(&self, req: ModerationRequest) -> BoxFuture<'_, Result<ModerationResponse>> {
1545 #[cfg(all(feature = "native-http", feature = "tower"))]
1546 if let Some(managed) = &self.managed {
1547 return managed.moderate(req);
1548 }
1549 Box::pin(async move {
1550 let model = req.model.as_deref().unwrap_or_default();
1551 let prepared = self.prepare_request(&req, |p| p.moderations_path(), model, None)?;
1552
1553 let (auth_header, credential) = self
1554 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1555 .await?;
1556 let all_headers = self.all_headers_for_provider(
1557 prepared.provider.as_ref(),
1558 credential.as_ref(),
1559 "POST",
1560 &prepared.url,
1561 &prepared.body_json,
1562 &prepared.body_bytes,
1563 )?;
1564 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1565
1566 let auth = auth_header.as_ref().map(str_pair);
1567 let mut raw = http::request::post_json_raw_bounded(
1568 &self.http,
1569 &prepared.url,
1570 auth,
1571 &extra,
1572 prepared.body_bytes,
1573 self.response_read_options(),
1574 )
1575 .await?;
1576 prepared.provider.transform_response(&mut raw)?;
1577 serde_json::from_value::<ModerationResponse>(raw).map_err(LiterLlmError::from)
1578 })
1579 }
1580
1581 fn rerank(&self, req: RerankRequest) -> BoxFuture<'_, Result<RerankResponse>> {
1582 #[cfg(all(feature = "native-http", feature = "tower"))]
1583 if let Some(managed) = &self.managed {
1584 return managed.rerank(req);
1585 }
1586 Box::pin(async move {
1587 let prepared = self.prepare_request(&req, |p| p.rerank_path(), &req.model, None)?;
1588
1589 let (auth_header, credential) = self
1590 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1591 .await?;
1592 let all_headers = self.all_headers_for_provider(
1593 prepared.provider.as_ref(),
1594 credential.as_ref(),
1595 "POST",
1596 &prepared.url,
1597 &prepared.body_json,
1598 &prepared.body_bytes,
1599 )?;
1600 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1601
1602 let auth = auth_header.as_ref().map(str_pair);
1603 let mut raw = http::request::post_json_raw_bounded(
1604 &self.http,
1605 &prepared.url,
1606 auth,
1607 &extra,
1608 prepared.body_bytes,
1609 self.response_read_options(),
1610 )
1611 .await?;
1612 prepared.provider.transform_response(&mut raw)?;
1613 serde_json::from_value::<RerankResponse>(raw).map_err(LiterLlmError::from)
1614 })
1615 }
1616
1617 fn search(&self, req: SearchRequest) -> BoxFuture<'_, Result<SearchResponse>> {
1618 #[cfg(all(feature = "native-http", feature = "tower"))]
1619 if let Some(managed) = &self.managed {
1620 return managed.search(req);
1621 }
1622 Box::pin(async move {
1623 let prepared = self.prepare_request(&req, |p| p.search_path(), &req.model, None)?;
1624
1625 let (auth_header, credential) = self
1626 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1627 .await?;
1628 let all_headers = self.all_headers_for_provider(
1629 prepared.provider.as_ref(),
1630 credential.as_ref(),
1631 "POST",
1632 &prepared.url,
1633 &prepared.body_json,
1634 &prepared.body_bytes,
1635 )?;
1636 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1637
1638 let auth = auth_header.as_ref().map(str_pair);
1639 let mut raw = http::request::post_json_raw_bounded(
1640 &self.http,
1641 &prepared.url,
1642 auth,
1643 &extra,
1644 prepared.body_bytes,
1645 self.response_read_options(),
1646 )
1647 .await?;
1648 prepared.provider.transform_response(&mut raw)?;
1649 serde_json::from_value::<SearchResponse>(raw).map_err(LiterLlmError::from)
1650 })
1651 }
1652
1653 fn ocr(&self, req: OcrRequest) -> BoxFuture<'_, Result<OcrResponse>> {
1654 #[cfg(all(feature = "native-http", feature = "tower"))]
1655 if let Some(managed) = &self.managed {
1656 return managed.ocr(req);
1657 }
1658 Box::pin(async move {
1659 let prepared = self.prepare_request(&req, |p| p.ocr_path(), &req.model, None)?;
1660
1661 let (auth_header, credential) = self
1662 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1663 .await?;
1664 let all_headers = self.all_headers_for_provider(
1665 prepared.provider.as_ref(),
1666 credential.as_ref(),
1667 "POST",
1668 &prepared.url,
1669 &prepared.body_json,
1670 &prepared.body_bytes,
1671 )?;
1672 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1673
1674 let auth = auth_header.as_ref().map(str_pair);
1675 let mut raw = http::request::post_json_raw_bounded(
1676 &self.http,
1677 &prepared.url,
1678 auth,
1679 &extra,
1680 prepared.body_bytes,
1681 self.response_read_options(),
1682 )
1683 .await?;
1684 prepared.provider.transform_response(&mut raw)?;
1685 serde_json::from_value::<OcrResponse>(raw).map_err(LiterLlmError::from)
1686 })
1687 }
1688}
1689
1690#[cfg(any(feature = "native-http", feature = "wasm-http"))]
1691impl LlmClientRaw for DefaultClient {
1692 fn chat_raw(&self, req: ChatCompletionRequest) -> BoxFuture<'_, Result<RawExchange<ChatCompletionResponse>>> {
1693 Box::pin(async move {
1694 let prepared = self.prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(false))?;
1695 let raw_request = prepared.body_json.clone();
1696
1697 let (auth_header, credential) = self
1698 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1699 .await?;
1700 let all_headers = self.all_headers_for_provider(
1701 prepared.provider.as_ref(),
1702 credential.as_ref(),
1703 "POST",
1704 &prepared.url,
1705 &prepared.body_json,
1706 &prepared.body_bytes,
1707 )?;
1708 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1709
1710 let auth = auth_header.as_ref().map(str_pair);
1711 let mut raw = http::request::post_json_raw_bounded(
1712 &self.http,
1713 &prepared.url,
1714 auth,
1715 &extra,
1716 prepared.body_bytes,
1717 self.response_read_options(),
1718 )
1719 .await?;
1720
1721 let raw_response = Some(raw.clone());
1722 prepared.provider.transform_response(&mut raw)?;
1723 let mut data = serde_json::from_value::<ChatCompletionResponse>(raw).map_err(LiterLlmError::from)?;
1724 attribute_chat_model(prepared.provider.as_ref(), &mut data.model, &req.model);
1725
1726 Ok(RawExchange {
1727 data,
1728 raw_request,
1729 raw_response,
1730 })
1731 })
1732 }
1733
1734 fn chat_stream_raw(
1735 &self,
1736 req: ChatCompletionRequest,
1737 ) -> BoxFuture<'_, Result<RawStreamExchange<BoxStream<'static, Result<ChatCompletionChunk>>>>> {
1738 Box::pin(async move {
1739 let prepared = self.prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(true))?;
1740 let raw_request = prepared.body_json.clone();
1741
1742 let bare_model = prepared.provider.strip_model_prefix(&req.model);
1743 let url = prepared
1744 .provider
1745 .build_stream_url(prepared.provider.chat_completions_path(), bare_model);
1746
1747 let (auth_header, credential) = self
1748 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1749 .await?;
1750 let all_headers = self.all_headers_for_provider(
1751 prepared.provider.as_ref(),
1752 credential.as_ref(),
1753 "POST",
1754 &url,
1755 &prepared.body_json,
1756 &prepared.body_bytes,
1757 )?;
1758 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1759 let auth = auth_header.as_ref().map(str_pair);
1760
1761 let stream = match prepared.provider.stream_format() {
1762 provider::StreamFormat::Sse => {
1763 let provider = Arc::clone(&prepared.provider);
1764 let state = std::sync::Mutex::new(ChatStreamState {
1765 model: req.model.clone(),
1766 ..Default::default()
1767 });
1768 let parse_event = move |data: &str| parse_chat_stream_event(provider.as_ref(), &state, data);
1769 http::streaming::post_stream_bounded(
1770 &self.http,
1771 http::request::StreamingPost {
1772 url: &url,
1773 auth_header: auth,
1774 extra_headers: &extra,
1775 body: prepared.body_bytes,
1776 },
1777 parse_event,
1778 self.response_read_options(),
1779 )
1780 .await?
1781 }
1782 provider::StreamFormat::AwsEventStream => {
1783 http::eventstream::post_eventstream_bounded(
1784 &self.http,
1785 http::request::StreamingPost {
1786 url: &url,
1787 auth_header: auth,
1788 extra_headers: &extra,
1789 body: prepared.body_bytes,
1790 },
1791 provider::bedrock::parse_bedrock_stream_event,
1792 self.response_read_options(),
1793 )
1794 .await?
1795 }
1796 };
1797
1798 Ok(RawStreamExchange { stream, raw_request })
1799 })
1800 }
1801
1802 fn embed_raw(&self, req: EmbeddingRequest) -> BoxFuture<'_, Result<RawExchange<EmbeddingResponse>>> {
1803 Box::pin(async move {
1804 let prepared = self.prepare_request(&req, |p| p.embeddings_path(), &req.model, None)?;
1805 let raw_request = prepared.body_json.clone();
1806
1807 let (auth_header, credential) = self
1808 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1809 .await?;
1810 let all_headers = self.all_headers_for_provider(
1811 prepared.provider.as_ref(),
1812 credential.as_ref(),
1813 "POST",
1814 &prepared.url,
1815 &prepared.body_json,
1816 &prepared.body_bytes,
1817 )?;
1818 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1819
1820 let auth = auth_header.as_ref().map(str_pair);
1821 let mut raw = http::request::post_json_raw_bounded(
1822 &self.http,
1823 &prepared.url,
1824 auth,
1825 &extra,
1826 prepared.body_bytes,
1827 self.response_read_options(),
1828 )
1829 .await?;
1830
1831 let raw_response = Some(raw.clone());
1832 prepared.provider.transform_response(&mut raw)?;
1833 let data = serde_json::from_value::<EmbeddingResponse>(raw).map_err(LiterLlmError::from)?;
1834
1835 Ok(RawExchange {
1836 data,
1837 raw_request,
1838 raw_response,
1839 })
1840 })
1841 }
1842
1843 fn image_generate_raw(&self, req: CreateImageRequest) -> BoxFuture<'_, Result<RawExchange<ImagesResponse>>> {
1844 Box::pin(async move {
1845 let model = req.model.as_deref().unwrap_or_default();
1846 let prepared = self.prepare_request(&req, |p| p.image_generations_path(), model, None)?;
1847 let raw_request = prepared.body_json.clone();
1848
1849 let (auth_header, credential) = self
1850 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1851 .await?;
1852 let all_headers = self.all_headers_for_provider(
1853 prepared.provider.as_ref(),
1854 credential.as_ref(),
1855 "POST",
1856 &prepared.url,
1857 &prepared.body_json,
1858 &prepared.body_bytes,
1859 )?;
1860 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1861
1862 let auth = auth_header.as_ref().map(str_pair);
1863 let mut raw = http::request::post_json_raw_bounded(
1864 &self.http,
1865 &prepared.url,
1866 auth,
1867 &extra,
1868 prepared.body_bytes,
1869 self.response_read_options(),
1870 )
1871 .await?;
1872
1873 let raw_response = Some(raw.clone());
1874 prepared.provider.transform_response(&mut raw)?;
1875 let data = serde_json::from_value::<ImagesResponse>(raw).map_err(LiterLlmError::from)?;
1876
1877 Ok(RawExchange {
1878 data,
1879 raw_request,
1880 raw_response,
1881 })
1882 })
1883 }
1884
1885 fn transcribe_raw(
1886 &self,
1887 req: CreateTranscriptionRequest,
1888 ) -> BoxFuture<'_, Result<RawExchange<TranscriptionResponse>>> {
1889 Box::pin(async move {
1890 let prepared = self.prepare_request(&req, |p| p.audio_transcriptions_path(), &req.model, None)?;
1891 let raw_request = prepared.body_json.clone();
1892
1893 let (auth_header, credential) = self
1894 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1895 .await?;
1896 let all_headers = self.all_headers_for_provider(
1897 prepared.provider.as_ref(),
1898 credential.as_ref(),
1899 "POST",
1900 &prepared.url,
1901 &prepared.body_json,
1902 &prepared.body_bytes,
1903 )?;
1904 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1905
1906 let auth = auth_header.as_ref().map(str_pair);
1907 let mut raw = http::request::post_json_raw_bounded(
1908 &self.http,
1909 &prepared.url,
1910 auth,
1911 &extra,
1912 prepared.body_bytes,
1913 self.response_read_options(),
1914 )
1915 .await?;
1916
1917 let raw_response = Some(raw.clone());
1918 prepared.provider.transform_response(&mut raw)?;
1919 let data = serde_json::from_value::<TranscriptionResponse>(raw).map_err(LiterLlmError::from)?;
1920
1921 Ok(RawExchange {
1922 data,
1923 raw_request,
1924 raw_response,
1925 })
1926 })
1927 }
1928
1929 fn moderate_raw(&self, req: ModerationRequest) -> BoxFuture<'_, Result<RawExchange<ModerationResponse>>> {
1930 Box::pin(async move {
1931 let model = req.model.as_deref().unwrap_or_default();
1932 let prepared = self.prepare_request(&req, |p| p.moderations_path(), model, None)?;
1933 let raw_request = prepared.body_json.clone();
1934
1935 let (auth_header, credential) = self
1936 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1937 .await?;
1938 let all_headers = self.all_headers_for_provider(
1939 prepared.provider.as_ref(),
1940 credential.as_ref(),
1941 "POST",
1942 &prepared.url,
1943 &prepared.body_json,
1944 &prepared.body_bytes,
1945 )?;
1946 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1947
1948 let auth = auth_header.as_ref().map(str_pair);
1949 let mut raw = http::request::post_json_raw_bounded(
1950 &self.http,
1951 &prepared.url,
1952 auth,
1953 &extra,
1954 prepared.body_bytes,
1955 self.response_read_options(),
1956 )
1957 .await?;
1958
1959 let raw_response = Some(raw.clone());
1960 prepared.provider.transform_response(&mut raw)?;
1961 let data = serde_json::from_value::<ModerationResponse>(raw).map_err(LiterLlmError::from)?;
1962
1963 Ok(RawExchange {
1964 data,
1965 raw_request,
1966 raw_response,
1967 })
1968 })
1969 }
1970
1971 fn rerank_raw(&self, req: RerankRequest) -> BoxFuture<'_, Result<RawExchange<RerankResponse>>> {
1972 Box::pin(async move {
1973 let prepared = self.prepare_request(&req, |p| p.rerank_path(), &req.model, None)?;
1974 let raw_request = prepared.body_json.clone();
1975
1976 let (auth_header, credential) = self
1977 .resolve_auth_header_for_provider(prepared.provider.as_ref())
1978 .await?;
1979 let all_headers = self.all_headers_for_provider(
1980 prepared.provider.as_ref(),
1981 credential.as_ref(),
1982 "POST",
1983 &prepared.url,
1984 &prepared.body_json,
1985 &prepared.body_bytes,
1986 )?;
1987 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
1988
1989 let auth = auth_header.as_ref().map(str_pair);
1990 let mut raw = http::request::post_json_raw_bounded(
1991 &self.http,
1992 &prepared.url,
1993 auth,
1994 &extra,
1995 prepared.body_bytes,
1996 self.response_read_options(),
1997 )
1998 .await?;
1999
2000 let raw_response = Some(raw.clone());
2001 prepared.provider.transform_response(&mut raw)?;
2002 let data = serde_json::from_value::<RerankResponse>(raw).map_err(LiterLlmError::from)?;
2003
2004 Ok(RawExchange {
2005 data,
2006 raw_request,
2007 raw_response,
2008 })
2009 })
2010 }
2011
2012 fn search_raw(&self, req: SearchRequest) -> BoxFuture<'_, Result<RawExchange<SearchResponse>>> {
2013 Box::pin(async move {
2014 let prepared = self.prepare_request(&req, |p| p.search_path(), &req.model, None)?;
2015 let raw_request = prepared.body_json.clone();
2016
2017 let (auth_header, credential) = self
2018 .resolve_auth_header_for_provider(prepared.provider.as_ref())
2019 .await?;
2020 let all_headers = self.all_headers_for_provider(
2021 prepared.provider.as_ref(),
2022 credential.as_ref(),
2023 "POST",
2024 &prepared.url,
2025 &prepared.body_json,
2026 &prepared.body_bytes,
2027 )?;
2028 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2029
2030 let auth = auth_header.as_ref().map(str_pair);
2031 let mut raw = http::request::post_json_raw_bounded(
2032 &self.http,
2033 &prepared.url,
2034 auth,
2035 &extra,
2036 prepared.body_bytes,
2037 self.response_read_options(),
2038 )
2039 .await?;
2040
2041 let raw_response = Some(raw.clone());
2042 prepared.provider.transform_response(&mut raw)?;
2043 let data = serde_json::from_value::<SearchResponse>(raw).map_err(LiterLlmError::from)?;
2044
2045 Ok(RawExchange {
2046 data,
2047 raw_request,
2048 raw_response,
2049 })
2050 })
2051 }
2052
2053 fn ocr_raw(&self, req: OcrRequest) -> BoxFuture<'_, Result<RawExchange<OcrResponse>>> {
2054 Box::pin(async move {
2055 let prepared = self.prepare_request(&req, |p| p.ocr_path(), &req.model, None)?;
2056 let raw_request = prepared.body_json.clone();
2057
2058 let (auth_header, credential) = self
2059 .resolve_auth_header_for_provider(prepared.provider.as_ref())
2060 .await?;
2061 let all_headers = self.all_headers_for_provider(
2062 prepared.provider.as_ref(),
2063 credential.as_ref(),
2064 "POST",
2065 &prepared.url,
2066 &prepared.body_json,
2067 &prepared.body_bytes,
2068 )?;
2069 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2070
2071 let auth = auth_header.as_ref().map(str_pair);
2072 let mut raw = http::request::post_json_raw_bounded(
2073 &self.http,
2074 &prepared.url,
2075 auth,
2076 &extra,
2077 prepared.body_bytes,
2078 self.response_read_options(),
2079 )
2080 .await?;
2081
2082 let raw_response = Some(raw.clone());
2083 prepared.provider.transform_response(&mut raw)?;
2084 let data = serde_json::from_value::<OcrResponse>(raw).map_err(LiterLlmError::from)?;
2085
2086 Ok(RawExchange {
2087 data,
2088 raw_request,
2089 raw_response,
2090 })
2091 })
2092 }
2093}
2094
2095#[cfg(any(feature = "native-http", feature = "wasm-http"))]
2096impl FileClient for DefaultClient {
2097 fn create_file(&self, req: CreateFileRequest) -> BoxFuture<'_, Result<FileObject>> {
2098 Box::pin(async move {
2099 let url = self.provider.build_url(self.provider.files_path(), "");
2100 let (auth_header, credential) = self.resolve_auth_header().await?;
2101 let auth = auth_header.as_ref().map(str_pair);
2102 let all_headers = self.all_headers(credential.as_ref(), "POST", &url, &serde_json::Value::Null, &[])?;
2103 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2104
2105 use base64::Engine;
2106 let file_bytes = base64::engine::general_purpose::STANDARD
2107 .decode(&req.file)
2108 .map_err(|e| LiterLlmError::BadRequest {
2109 message: format!("invalid base64 file data: {e}"),
2110 status: 400,
2111 })?;
2112
2113 let filename = req.filename.unwrap_or_else(|| "upload".to_owned());
2114 let file_part = reqwest::multipart::Part::bytes(file_bytes).file_name(filename);
2115 let purpose_str = serde_json::to_value(&req.purpose)?
2116 .as_str()
2117 .unwrap_or_default()
2118 .to_owned();
2119 let form = reqwest::multipart::Form::new()
2120 .part("file", file_part)
2121 .text("purpose", purpose_str);
2122
2123 let raw = http::request::post_multipart_bounded(
2124 &self.http,
2125 &url,
2126 auth,
2127 &extra,
2128 form,
2129 self.config.max_response_bytes,
2130 )
2131 .await?;
2132 serde_json::from_value::<FileObject>(raw).map_err(LiterLlmError::from)
2133 })
2134 }
2135
2136 fn retrieve_file(&self, file_id: &str) -> BoxFuture<'_, Result<FileObject>> {
2137 let file_id = file_id.to_owned();
2138 Box::pin(async move {
2139 let url = format!(
2140 "{}/{}",
2141 self.provider.build_url(self.provider.files_path(), ""),
2142 file_id
2143 );
2144 let (auth_header, credential) = self.resolve_auth_header().await?;
2145 let auth = auth_header.as_ref().map(str_pair);
2146 let all_headers = self.all_headers(credential.as_ref(), "GET", &url, &serde_json::Value::Null, &[])?;
2147 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2148
2149 let raw = http::request::get_json_raw_bounded(
2150 &self.http,
2151 &url,
2152 auth,
2153 &extra,
2154 self.config.max_retries,
2155 self.config.max_response_bytes,
2156 )
2157 .await?;
2158 serde_json::from_value::<FileObject>(raw).map_err(LiterLlmError::from)
2159 })
2160 }
2161
2162 fn delete_file(&self, file_id: &str) -> BoxFuture<'_, Result<DeleteResponse>> {
2163 let file_id = file_id.to_owned();
2164 Box::pin(async move {
2165 let url = format!(
2166 "{}/{}",
2167 self.provider.build_url(self.provider.files_path(), ""),
2168 file_id
2169 );
2170 let (auth_header, credential) = self.resolve_auth_header().await?;
2171 let auth = auth_header.as_ref().map(str_pair);
2172 let all_headers = self.all_headers(credential.as_ref(), "DELETE", &url, &serde_json::Value::Null, &[])?;
2173 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2174
2175 let raw = http::request::delete_json_bounded(
2176 &self.http,
2177 &url,
2178 auth,
2179 &extra,
2180 self.config.max_retries,
2181 self.config.max_response_bytes,
2182 )
2183 .await?;
2184 serde_json::from_value::<DeleteResponse>(raw).map_err(LiterLlmError::from)
2185 })
2186 }
2187
2188 fn list_files(&self, query: Option<FileListQuery>) -> BoxFuture<'_, Result<FileListResponse>> {
2189 Box::pin(async move {
2190 let base_url = self.provider.build_url(self.provider.files_path(), "");
2191 let url = if let Some(ref q) = query {
2192 let mut params = Vec::new();
2193 if let Some(ref purpose) = q.purpose {
2194 params.push(format!("purpose={purpose}"));
2195 }
2196 if let Some(limit) = q.limit {
2197 params.push(format!("limit={limit}"));
2198 }
2199 if let Some(ref after) = q.after {
2200 params.push(format!("after={after}"));
2201 }
2202 if params.is_empty() {
2203 base_url
2204 } else {
2205 format!("{base_url}?{}", params.join("&"))
2206 }
2207 } else {
2208 base_url
2209 };
2210 let (auth_header, credential) = self.resolve_auth_header().await?;
2211 let auth = auth_header.as_ref().map(str_pair);
2212 let all_headers = self.all_headers(credential.as_ref(), "GET", &url, &serde_json::Value::Null, &[])?;
2213 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2214
2215 let raw = http::request::get_json_raw_bounded(
2216 &self.http,
2217 &url,
2218 auth,
2219 &extra,
2220 self.config.max_retries,
2221 self.config.max_response_bytes,
2222 )
2223 .await?;
2224 serde_json::from_value::<FileListResponse>(raw).map_err(LiterLlmError::from)
2225 })
2226 }
2227
2228 fn file_content(&self, file_id: &str) -> BoxFuture<'_, Result<bytes::Bytes>> {
2229 let file_id = file_id.to_owned();
2230 Box::pin(async move {
2231 let url = format!(
2232 "{}/{}/content",
2233 self.provider.build_url(self.provider.files_path(), ""),
2234 file_id
2235 );
2236 let (auth_header, credential) = self.resolve_auth_header().await?;
2237 let auth = auth_header.as_ref().map(str_pair);
2238 let all_headers = self.all_headers(credential.as_ref(), "GET", &url, &serde_json::Value::Null, &[])?;
2239 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2240
2241 http::request::get_binary_bounded(
2242 &self.http,
2243 &url,
2244 auth,
2245 &extra,
2246 self.config.max_retries,
2247 self.config.max_response_bytes,
2248 )
2249 .await
2250 })
2251 }
2252}
2253
2254#[cfg(any(feature = "native-http", feature = "wasm-http"))]
2255impl BatchClient for DefaultClient {
2256 fn create_batch(&self, req: CreateBatchRequest) -> BoxFuture<'_, Result<BatchObject>> {
2257 Box::pin(async move {
2258 let url = self.provider.build_url(self.provider.batches_path(), "");
2259 let body_bytes = bytes::Bytes::from(serde_json::to_vec(&req)?);
2260 let body_json = serde_json::to_value(&req)?;
2261
2262 let (auth_header, credential) = self.resolve_auth_header().await?;
2263 let all_headers = self.all_headers(credential.as_ref(), "POST", &url, &body_json, &body_bytes)?;
2264 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2265 let auth = auth_header.as_ref().map(str_pair);
2266
2267 let raw = http::request::post_json_raw_bounded(
2268 &self.http,
2269 &url,
2270 auth,
2271 &extra,
2272 body_bytes,
2273 self.response_read_options(),
2274 )
2275 .await?;
2276 serde_json::from_value::<BatchObject>(raw).map_err(LiterLlmError::from)
2277 })
2278 }
2279
2280 fn retrieve_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>> {
2281 let batch_id = batch_id.to_owned();
2282 Box::pin(async move {
2283 let url = format!(
2284 "{}/{}",
2285 self.provider.build_url(self.provider.batches_path(), ""),
2286 batch_id
2287 );
2288 let (auth_header, credential) = self.resolve_auth_header().await?;
2289 let auth = auth_header.as_ref().map(str_pair);
2290 let all_headers = self.all_headers(credential.as_ref(), "GET", &url, &serde_json::Value::Null, &[])?;
2291 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2292
2293 let raw = http::request::get_json_raw_bounded(
2294 &self.http,
2295 &url,
2296 auth,
2297 &extra,
2298 self.config.max_retries,
2299 self.config.max_response_bytes,
2300 )
2301 .await?;
2302 serde_json::from_value::<BatchObject>(raw).map_err(LiterLlmError::from)
2303 })
2304 }
2305
2306 fn list_batches(&self, query: Option<BatchListQuery>) -> BoxFuture<'_, Result<BatchListResponse>> {
2307 Box::pin(async move {
2308 let base_url = self.provider.build_url(self.provider.batches_path(), "");
2309 let url = if let Some(ref q) = query {
2310 let mut params = Vec::new();
2311 if let Some(limit) = q.limit {
2312 params.push(format!("limit={limit}"));
2313 }
2314 if let Some(ref after) = q.after {
2315 params.push(format!("after={after}"));
2316 }
2317 if params.is_empty() {
2318 base_url
2319 } else {
2320 format!("{base_url}?{}", params.join("&"))
2321 }
2322 } else {
2323 base_url
2324 };
2325 let (auth_header, credential) = self.resolve_auth_header().await?;
2326 let auth = auth_header.as_ref().map(str_pair);
2327 let all_headers = self.all_headers(credential.as_ref(), "GET", &url, &serde_json::Value::Null, &[])?;
2328 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2329
2330 let raw = http::request::get_json_raw_bounded(
2331 &self.http,
2332 &url,
2333 auth,
2334 &extra,
2335 self.config.max_retries,
2336 self.config.max_response_bytes,
2337 )
2338 .await?;
2339 serde_json::from_value::<BatchListResponse>(raw).map_err(LiterLlmError::from)
2340 })
2341 }
2342
2343 fn cancel_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>> {
2344 let batch_id = batch_id.to_owned();
2345 Box::pin(async move {
2346 let url = format!(
2347 "{}/{}/cancel",
2348 self.provider.build_url(self.provider.batches_path(), ""),
2349 batch_id
2350 );
2351 let (auth_header, credential) = self.resolve_auth_header().await?;
2352 let body_json = serde_json::Value::Null;
2353 let body_bytes = bytes::Bytes::new();
2354 let all_headers = self.all_headers(credential.as_ref(), "POST", &url, &body_json, &body_bytes)?;
2355 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2356 let auth = auth_header.as_ref().map(str_pair);
2357
2358 let raw = http::request::post_json_raw_bounded(
2359 &self.http,
2360 &url,
2361 auth,
2362 &extra,
2363 body_bytes,
2364 self.response_read_options(),
2365 )
2366 .await?;
2367 serde_json::from_value::<BatchObject>(raw).map_err(LiterLlmError::from)
2368 })
2369 }
2370}
2371
2372#[cfg(any(feature = "native-http", feature = "wasm-http"))]
2377#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
2378#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
2379#[doc(hidden)]
2380#[cfg_attr(alef, alef(skip))]
2381pub trait BatchRetriever {
2382 async fn fetch_batch_for_polling(&self, batch_id: &str) -> Result<BatchObject>;
2384}
2385
2386#[cfg(any(feature = "native-http", feature = "wasm-http"))]
2387#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
2388#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
2389impl BatchRetriever for DefaultClient {
2390 async fn fetch_batch_for_polling(&self, batch_id: &str) -> Result<BatchObject> {
2391 self.retrieve_batch(batch_id).await
2392 }
2393}
2394
2395#[cfg(any(feature = "native-http", feature = "wasm-http"))]
2399#[doc(hidden)]
2400#[cfg_attr(alef, alef(skip))]
2401pub async fn wait_for_batch_impl<R: BatchRetriever>(
2402 retriever: &R,
2403 batch_id: &str,
2404 config: WaitForBatchConfig,
2405) -> std::result::Result<BatchObject, BatchWaitError> {
2406 #[cfg(not(target_arch = "wasm32"))]
2407 let started = tokio::time::Instant::now();
2408 #[cfg(target_arch = "wasm32")]
2409 let started = web_time::Instant::now();
2410 let mut interval_secs = config.initial_interval_secs;
2411
2412 loop {
2413 let batch = retriever.fetch_batch_for_polling(batch_id).await?;
2414
2415 match batch.status {
2416 BatchStatus::Completed => return Ok(batch),
2417 BatchStatus::Failed | BatchStatus::Expired | BatchStatus::Cancelled => {
2418 return Err(BatchWaitError::Failed { status: batch.status });
2419 }
2420 BatchStatus::Validating | BatchStatus::InProgress | BatchStatus::Finalizing | BatchStatus::Cancelling => {
2421 if let Some(timeout_secs) = config.timeout_secs {
2422 let timeout = Duration::from_secs_f64(timeout_secs);
2423 if started.elapsed() >= timeout {
2424 return Err(BatchWaitError::Timeout { timeout_secs });
2425 }
2426 }
2427 #[cfg(not(target_arch = "wasm32"))]
2428 tokio::time::sleep(Duration::from_secs_f64(interval_secs)).await;
2429 #[cfg(target_arch = "wasm32")]
2430 gloo_timers::future::sleep(Duration::from_secs_f64(interval_secs)).await;
2431 let next =
2432 (interval_secs as f32 * config.backoff_multiplier).min(config.max_interval_secs as f32) as f64;
2433 interval_secs = next;
2434 }
2435 }
2436 }
2437}
2438
2439#[cfg(any(feature = "native-http", feature = "wasm-http"))]
2440impl DefaultClient {
2441 pub async fn wait_for_batch(
2464 &self,
2465 batch_id: &str,
2466 config: WaitForBatchConfig,
2467 ) -> std::result::Result<BatchObject, BatchWaitError> {
2468 wait_for_batch_impl(self, batch_id, config).await
2469 }
2470}
2471
2472#[cfg(any(feature = "native-http", feature = "wasm-http"))]
2483fn parse_response_stream_event(data: &str) -> Result<Option<ResponseStreamEvent>> {
2484 serde_json::from_str::<ResponseStreamEvent>(data)
2485 .map(Some)
2486 .map_err(|e| LiterLlmError::Streaming {
2487 message: format!("failed to parse Responses SSE data: {e}"),
2488 })
2489}
2490
2491#[cfg(any(feature = "native-http", feature = "wasm-http"))]
2516fn reject_malformed_responses_url(url: &str, provider_name: &str) -> Result<()> {
2517 let path = url.split_once("://").map_or(url, |(_, rest)| rest);
2518 let path = path.split_once('/').map_or("", |(_, rest)| rest);
2519 let path = path.split(['?', '#']).next().unwrap_or("");
2520 if format!("/{path}").contains("//") {
2521 return Err(LiterLlmError::EndpointNotSupported {
2522 endpoint: "responses".into(),
2523 provider: provider_name.into(),
2524 });
2525 }
2526 Ok(())
2527}
2528
2529#[cfg(any(feature = "native-http", feature = "wasm-http"))]
2530impl ResponseClient for DefaultClient {
2531 fn create_response(&self, req: CreateResponseRequest) -> BoxFuture<'_, Result<ResponseObject>> {
2532 Box::pin(async move {
2533 let url = self.provider.build_url(self.provider.responses_path(), "");
2534 reject_malformed_responses_url(&url, self.provider.name())?;
2535 let body_json = response_request_body(&req)?;
2536 let body_bytes = bytes::Bytes::from(serde_json::to_vec(&body_json)?);
2537
2538 let (auth_header, credential) = self.resolve_auth_header().await?;
2539 let all_headers = self.all_headers(credential.as_ref(), "POST", &url, &body_json, &body_bytes)?;
2540 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2541 let auth = auth_header.as_ref().map(str_pair);
2542
2543 let raw = http::request::post_json_raw_bounded(
2544 &self.http,
2545 &url,
2546 auth,
2547 &extra,
2548 body_bytes,
2549 self.response_read_options(),
2550 )
2551 .await?;
2552 serde_json::from_value::<ResponseObject>(raw).map_err(LiterLlmError::from)
2553 })
2554 }
2555
2556 fn create_response_stream(
2557 &self,
2558 req: CreateResponseRequest,
2559 ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ResponseStreamEvent>>>> {
2560 Box::pin(async move {
2561 let url = self.provider.build_stream_url(self.provider.responses_path(), "");
2563 reject_malformed_responses_url(&url, self.provider.name())?;
2564 let body_json = response_stream_request_body(req)?;
2565 let body_bytes = bytes::Bytes::from(serde_json::to_vec(&body_json)?);
2566
2567 let (auth_header, credential) = self.resolve_auth_header().await?;
2568 let all_headers = self.all_headers(credential.as_ref(), "POST", &url, &body_json, &body_bytes)?;
2569 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2570 let auth = auth_header.as_ref().map(str_pair);
2571
2572 http::streaming::post_stream_bounded(
2573 &self.http,
2574 http::request::StreamingPost {
2575 url: &url,
2576 auth_header: auth,
2577 extra_headers: &extra,
2578 body: body_bytes,
2579 },
2580 parse_response_stream_event,
2581 self.response_read_options(),
2582 )
2583 .await
2584 })
2585 }
2586
2587 fn retrieve_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>> {
2588 let response_id = response_id.to_owned();
2589 Box::pin(async move {
2590 let base_url = self.provider.build_url(self.provider.responses_path(), "");
2591 reject_malformed_responses_url(&base_url, self.provider.name())?;
2592 let url = format!("{base_url}/{response_id}");
2593 let (auth_header, credential) = self.resolve_auth_header().await?;
2594 let auth = auth_header.as_ref().map(str_pair);
2595 let all_headers = self.all_headers(credential.as_ref(), "GET", &url, &serde_json::Value::Null, &[])?;
2596 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2597
2598 let raw = http::request::get_json_raw_bounded(
2599 &self.http,
2600 &url,
2601 auth,
2602 &extra,
2603 self.config.max_retries,
2604 self.config.max_response_bytes,
2605 )
2606 .await?;
2607 serde_json::from_value::<ResponseObject>(raw).map_err(LiterLlmError::from)
2608 })
2609 }
2610
2611 fn cancel_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>> {
2612 let response_id = response_id.to_owned();
2613 Box::pin(async move {
2614 let base_url = self.provider.build_url(self.provider.responses_path(), "");
2615 reject_malformed_responses_url(&base_url, self.provider.name())?;
2616 let url = format!("{base_url}/{response_id}/cancel");
2617 let (auth_header, credential) = self.resolve_auth_header().await?;
2618 let body_json = serde_json::Value::Null;
2619 let body_bytes = bytes::Bytes::new();
2620 let all_headers = self.all_headers(credential.as_ref(), "POST", &url, &body_json, &body_bytes)?;
2621 let extra: Vec<(&str, &str)> = all_headers.iter().map(|(n, v)| (n.as_str(), v.as_str())).collect();
2622 let auth = auth_header.as_ref().map(str_pair);
2623
2624 let raw = http::request::post_json_raw_bounded(
2625 &self.http,
2626 &url,
2627 auth,
2628 &extra,
2629 body_bytes,
2630 self.response_read_options(),
2631 )
2632 .await?;
2633 serde_json::from_value::<ResponseObject>(raw).map_err(LiterLlmError::from)
2634 })
2635 }
2636}
2637
2638#[cfg(all(test, any(feature = "native-http", feature = "wasm-http")))]
2639mod stream_termination_tests {
2640 use futures_util::StreamExt;
2641
2642 use super::*;
2643 use crate::types::{FinishReason, StreamChoice};
2644
2645 fn chunk(finish_reason: Option<FinishReason>) -> Result<ChatCompletionChunk> {
2646 Ok(ChatCompletionChunk {
2647 choices: vec![StreamChoice {
2648 finish_reason,
2649 ..Default::default()
2650 }],
2651 ..Default::default()
2652 })
2653 }
2654
2655 async fn drain(items: Vec<Result<ChatCompletionChunk>>) -> Vec<Result<ChatCompletionChunk>> {
2656 RequireTerminalChunk::wrap(Box::pin(futures_util::stream::iter(items)), "anthropic".into())
2657 .collect()
2658 .await
2659 }
2660
2661 #[tokio::test]
2662 async fn finish_reason_marks_the_stream_complete() {
2663 let items = drain(vec![chunk(None), chunk(Some(FinishReason::Stop)), chunk(None)]).await;
2664 assert_eq!(items.len(), 3);
2665 assert!(items.iter().all(Result::is_ok));
2666 }
2667
2668 #[tokio::test]
2669 async fn clean_end_without_finish_reason_is_a_streaming_error() {
2670 let items = drain(vec![chunk(None)]).await;
2671 assert_eq!(items.len(), 2);
2672 assert!(matches!(items[1], Err(LiterLlmError::Streaming { .. })));
2673 }
2674
2675 #[tokio::test]
2676 async fn an_error_already_reported_is_not_followed_by_another() {
2677 let items = drain(vec![
2678 chunk(None),
2679 Err(LiterLlmError::Streaming {
2680 message: "cut mid-line".into(),
2681 }),
2682 ])
2683 .await;
2684 assert_eq!(items.len(), 2);
2685 }
2686
2687 #[test]
2688 fn only_first_party_protocols_require_a_final_event() {
2689 assert!(requires_terminal_chat_chunk(
2690 &provider::anthropic::AnthropicProvider::default()
2691 ));
2692 assert!(requires_terminal_chat_chunk(&provider::cohere::CohereProvider));
2693 assert!(!requires_terminal_chat_chunk(
2694 provider::detect_provider("openai/gpt-4o")
2695 .expect("OpenAI is a built-in provider")
2696 .as_ref()
2697 ));
2698 }
2699}
2700
2701#[cfg(all(test, any(feature = "native-http", feature = "wasm-http")))]
2702mod build_provider_tests {
2703 use super::*;
2704 use crate::client::config::ClientConfigBuilder;
2705
2706 #[test]
2711 fn reject_malformed_responses_url_rejects_azure_empty_deployment() {
2712 let provider = provider::azure::AzureProvider::with_base_url("https://resourceA.openai.azure.com");
2713 let url = provider.build_url(provider.responses_path(), "");
2714
2715 assert!(
2716 url.contains("/openai/deployments//responses"),
2717 "test setup assumption broken, url = {url}"
2718 );
2719
2720 let result = reject_malformed_responses_url(&url, provider.name());
2721 match result {
2722 Err(LiterLlmError::EndpointNotSupported {
2723 endpoint,
2724 provider: rejected_provider,
2725 }) => {
2726 assert_eq!(endpoint, "responses");
2727 assert_eq!(rejected_provider, "azure");
2728 }
2729 other => panic!("expected Err(EndpointNotSupported), got {other:?}"),
2730 }
2731 }
2732
2733 #[test]
2737 fn reject_malformed_responses_url_allows_azure_with_pinned_deployment() {
2738 let base_url = "https://resourceA.openai.azure.com/openai/deployments/my-gpt4-deployment";
2739 let provider = provider::azure::AzureProvider::with_base_url(base_url);
2740 let url = provider.build_url(provider.responses_path(), "");
2741
2742 assert!(
2743 !url.contains("deployments//"),
2744 "test setup assumption broken: pinned-deployment URL should have no empty segment, url = {url}"
2745 );
2746
2747 assert!(
2748 reject_malformed_responses_url(&url, provider.name()).is_ok(),
2749 "a pinned-deployment Azure URL must not be rejected, url = {url}"
2750 );
2751 }
2752
2753 #[test]
2757 fn reject_malformed_responses_url_allows_bedrock_google_ai_and_vertex() {
2758 let bedrock = provider::bedrock::BedrockProvider::from_config(
2759 Some("us-east-1".into()),
2760 None,
2761 Some("test-access-key".into()),
2762 Some("test-secret-key".into()),
2763 None,
2764 );
2765 let bedrock_url = bedrock.build_url(bedrock.responses_path(), "");
2766 assert!(
2767 reject_malformed_responses_url(&bedrock_url, bedrock.name()).is_ok(),
2768 "bedrock url = {bedrock_url}"
2769 );
2770
2771 let google_ai = provider::google_ai::GoogleAiProvider::default();
2772 let google_ai_url = google_ai.build_url(google_ai.responses_path(), "");
2773 assert!(
2774 reject_malformed_responses_url(&google_ai_url, google_ai.name()).is_ok(),
2775 "google ai url = {google_ai_url}"
2776 );
2777
2778 let vertex = provider::vertex::VertexAiProvider::new("my-project", "us-central1");
2779 let vertex_url = vertex.build_url(vertex.responses_path(), "");
2780 assert!(
2781 reject_malformed_responses_url(&vertex_url, vertex.name()).is_ok(),
2782 "vertex url = {vertex_url}"
2783 );
2784 }
2785
2786 #[test]
2787 fn azure_model_with_per_model_base_url_uses_azure_provider() {
2788 let config = ClientConfigBuilder::new("test-key")
2789 .base_url("https://resourceA.cognitiveservices.azure.com")
2790 .build();
2791 let p = build_provider(&config, Some("azure/gpt-5-mini"));
2792 assert_eq!(p.name(), "azure");
2793 let url = p.build_url("/chat/completions", "gpt-5-mini");
2794 assert!(
2795 url.starts_with("https://resourceA.cognitiveservices.azure.com/openai/deployments/gpt-5-mini/chat/completions?api-version="),
2796 "url = {url}"
2797 );
2798 }
2799
2800 #[test]
2801 fn non_azure_model_with_base_url_uses_openai_compatible() {
2802 let config = ClientConfigBuilder::new("test-key")
2803 .base_url("http://localhost:11434/v1")
2804 .build();
2805 let p = build_provider(&config, Some("llama3.1:8b"));
2806 assert_eq!(p.name(), "custom");
2807 let url = p.build_url("/chat/completions", "llama3.1:8b");
2808 assert_eq!(url, "http://localhost:11434/v1/chat/completions");
2809 }
2810
2811 #[test]
2812 fn base_url_path_is_joined_before_existing_query() {
2813 let config = ClientConfigBuilder::new("test-key")
2814 .base_url("http://localhost:11434/v1?token=secret")
2815 .build();
2816 let provider = build_provider(&config, Some("local-model"));
2817 let url = provider.build_url("/chat/completions", "local-model");
2818 assert_eq!(url, "http://localhost:11434/v1/chat/completions?token=secret");
2819 }
2820
2821 #[test]
2822 fn native_provider_prefixes_keep_transforms_with_base_url() {
2823 let config = ClientConfigBuilder::new("test-key")
2824 .base_url("http://localhost:8080/native")
2825 .build();
2826
2827 for (model, expected_name, expected_path) in [
2828 ("gemini/gemini-2.5-flash", "google_ai", ":generateContent"),
2829 ("google_ai/gemini-2.5-flash", "google_ai", ":generateContent"),
2830 ("vertex_ai/gemini-2.5-flash", "vertex_ai", ":generateContent"),
2831 ("bedrock/anthropic.claude-v2", "bedrock", "/model/"),
2832 ] {
2833 let provider = build_provider(&config, Some(model));
2834 let bare_model = provider.strip_model_prefix(model);
2835 let url = provider.build_url(provider.chat_completions_path(), bare_model);
2836 assert_eq!(provider.name(), expected_name);
2837 assert!(url.starts_with("http://localhost:8080/native"), "url = {url}");
2838 assert!(url.contains(expected_path), "url = {url}");
2839 }
2840 }
2841
2842 #[test]
2843 fn base_url_provider_strips_only_hint_provider_and_self_prefix() {
2844 let config = ClientConfigBuilder::new("test-key")
2845 .base_url("http://localhost:11434/v1")
2846 .build();
2847
2848 let hinted = build_provider(&config, Some("openai"));
2849 assert_eq!(hinted.strip_model_prefix("openai/gpt-4o-mini"), "gpt-4o-mini");
2850 assert_eq!(hinted.strip_model_prefix("custom/my-model"), "my-model");
2851 assert_eq!(hinted.strip_model_prefix("anthropic/claude-x"), "anthropic/claude-x");
2852 assert_eq!(hinted.strip_model_prefix("meta-llama/llama-3"), "meta-llama/llama-3");
2853 assert_eq!(hinted.strip_model_prefix("llama3.1:8b"), "llama3.1:8b");
2854 assert_eq!(hinted.strip_model_prefix("openai/"), "openai/");
2855
2856 let hinted_with_model = build_provider(&config, Some("openai/gpt-4o-mini"));
2857 assert_eq!(hinted_with_model.strip_model_prefix("openai/gpt-4o"), "gpt-4o");
2858
2859 let unhinted = build_provider(&config, None);
2860 assert_eq!(unhinted.strip_model_prefix("openai/gpt-4o-mini"), "openai/gpt-4o-mini");
2861 assert_eq!(unhinted.strip_model_prefix("custom/my-model"), "my-model");
2862 }
2863
2864 #[test]
2865 fn no_base_url_falls_through_to_detect_provider() {
2866 let config = ClientConfigBuilder::new("test-key").build();
2867 let p = build_provider(&config, Some("azure/gpt-4o"));
2868 assert_eq!(p.name(), "azure");
2869 }
2870
2871 #[test]
2872 fn anthropic_model_with_per_model_base_url_uses_anthropic_provider() {
2873 let config = ClientConfigBuilder::new("test-key")
2874 .base_url("https://proxy.internal/anthropic/")
2875 .build();
2876 let p = build_provider(&config, Some("anthropic/claude-3-5-sonnet-20241022"));
2877 assert_eq!(p.name(), "anthropic");
2878 assert_eq!(p.base_url(), "https://proxy.internal/anthropic");
2879 }
2880
2881 #[test]
2882 fn default_anthropic_provider_uses_official_base_url() {
2883 assert_eq!(
2884 provider::anthropic::AnthropicProvider::new().base_url(),
2885 "https://api.anthropic.com/v1"
2886 );
2887 assert_eq!(
2888 provider::anthropic::AnthropicProvider::default().base_url(),
2889 "https://api.anthropic.com/v1"
2890 );
2891 }
2892
2893 #[test]
2894 fn extra_body_object_is_merged_and_overrides_existing_key() {
2895 let client = DefaultClient::new(ClientConfigBuilder::new("test-key").build(), Some("gpt-4"))
2896 .expect("client construction should succeed");
2897 let req = ChatCompletionRequest {
2898 model: "gpt-4".into(),
2899 messages: vec![],
2900 extra_body: Some(serde_json::json!({
2901 "thinking": {"type": "enabled"},
2902 "model": "extra-body-override"
2903 })),
2904 ..Default::default()
2905 };
2906
2907 let prepared = client
2908 .prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(false))
2909 .expect("prepare_request should not fail");
2910
2911 assert!(
2912 prepared.body_json.get("extra_body").is_none(),
2913 "extra_body key must be removed from the final body"
2914 );
2915 assert_eq!(prepared.body_json["thinking"], serde_json::json!({"type": "enabled"}));
2916 assert_eq!(
2917 prepared.body_json["model"], "extra-body-override",
2918 "extra_body keys must override identically named top-level keys"
2919 );
2920 }
2921
2922 #[test]
2923 fn extra_body_cannot_override_the_transport_controlled_stream_flag() {
2924 let client = DefaultClient::new(ClientConfigBuilder::new("test-key").build(), Some("gpt-4"))
2925 .expect("client construction should succeed");
2926 let req = ChatCompletionRequest {
2927 model: "gpt-4".into(),
2928 messages: vec![],
2929 extra_body: Some(serde_json::json!({"stream": false})),
2930 ..Default::default()
2931 };
2932
2933 let prepared = client
2934 .prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(true))
2935 .expect("prepare_request should not fail");
2936
2937 assert_eq!(
2938 prepared.body_json["stream"],
2939 serde_json::json!(true),
2940 "the caller-selected stream flag must win over extra_body"
2941 );
2942 }
2943
2944 #[test]
2945 fn extra_body_non_object_is_dropped_without_reaching_the_body() {
2946 let client = DefaultClient::new(ClientConfigBuilder::new("test-key").build(), Some("gpt-4"))
2947 .expect("client construction should succeed");
2948 let req = ChatCompletionRequest {
2949 model: "gpt-4".into(),
2950 messages: vec![],
2951 extra_body: Some(serde_json::json!(["not", "an", "object"])),
2952 ..Default::default()
2953 };
2954
2955 let prepared = client
2956 .prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(false))
2957 .expect("prepare_request should not fail");
2958
2959 assert!(
2960 prepared.body_json.get("extra_body").is_none(),
2961 "non-object extra_body must never reach the wire"
2962 );
2963 }
2964
2965 #[test]
2966 fn anthropic_provider_leaves_no_extra_body_key_in_final_body() {
2967 let client = DefaultClient::new(
2968 ClientConfigBuilder::new("test-key").build(),
2969 Some("claude-3-5-sonnet-20241022"),
2970 )
2971 .expect("client construction should succeed");
2972 let req = ChatCompletionRequest {
2973 model: "claude-3-5-sonnet-20241022".into(),
2974 messages: vec![crate::types::Message::User(crate::types::UserMessage {
2975 content: crate::types::UserContent::Text("Hi".into()),
2976 name: None,
2977 })],
2978 max_tokens: Some(100),
2979 extra_body: Some(serde_json::json!({"reasoning_effort": "high"})),
2980 ..Default::default()
2981 };
2982
2983 let prepared = client
2984 .prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(false))
2985 .expect("prepare_request should not fail");
2986
2987 assert!(
2988 prepared.body_json.get("extra_body").is_none(),
2989 "anthropic's own transform_request already strips extra_body"
2990 );
2991 assert_eq!(prepared.body_json["thinking"]["budget_tokens"], 16384);
2992 }
2993
2994 fn base_response_request() -> CreateResponseRequest {
2995 CreateResponseRequest {
2996 model: "gpt-5".into(),
2997 input: serde_json::json!("What is the capital of France?"),
2998 ..Default::default()
2999 }
3000 }
3001
3002 #[test]
3003 fn responses_extra_body_object_is_merged_and_overrides_existing_key() {
3004 let mut req = base_response_request();
3005 req.extra_body = Some(serde_json::json!({
3006 "reasoning": {"effort": "none"},
3007 "model": "extra-body-override"
3008 }));
3009
3010 let body = response_request_body(&req).expect("body should serialize");
3011
3012 assert!(
3013 body.get("extra_body").is_none(),
3014 "extra_body key must be removed from the final Responses body"
3015 );
3016 assert_eq!(body["reasoning"], serde_json::json!({"effort": "none"}));
3017 assert_eq!(
3018 body["model"], "extra-body-override",
3019 "extra_body keys must override identically named top-level keys, matching chat-path semantics"
3020 );
3021 }
3022
3023 #[test]
3024 fn responses_extra_body_non_object_is_dropped_without_reaching_the_body() {
3025 let mut req = base_response_request();
3026 req.extra_body = Some(serde_json::json!(["not", "an", "object"]));
3027
3028 let body = response_request_body(&req).expect("body should serialize");
3029
3030 assert!(
3031 body.get("extra_body").is_none(),
3032 "non-object extra_body must never reach the wire"
3033 );
3034 assert_eq!(
3035 body["model"], "gpt-5",
3036 "a dropped extra_body must leave the real fields untouched"
3037 );
3038 }
3039
3040 #[test]
3041 fn responses_streaming_path_also_merges_extra_body_and_keeps_stream_forced() {
3042 let mut req = base_response_request();
3043 req.extra_body = Some(serde_json::json!({
3044 "reasoning": {"effort": "none"},
3045 "stream": false
3046 }));
3047
3048 let body = response_stream_request_body(req).expect("body should serialize");
3049
3050 assert!(
3051 body.get("extra_body").is_none(),
3052 "extra_body key must be removed from the streaming Responses body too"
3053 );
3054 assert_eq!(body["reasoning"], serde_json::json!({"effort": "none"}));
3055 assert_eq!(
3056 body["stream"],
3057 serde_json::json!(true),
3058 "the streaming transport's stream flag must win over extra_body"
3059 );
3060 }
3061
3062 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
3068 #[test]
3069 #[serial_test::serial]
3070 fn vertex_ai_auto_installs_adc_provider_when_no_credentials_configured() {
3071 let prior_project = std::env::var("VERTEXAI_PROJECT").ok();
3074 let prior_location = std::env::var("VERTEXAI_LOCATION").ok();
3075 struct EnvGuard {
3076 prior_project: Option<String>,
3077 prior_location: Option<String>,
3078 }
3079 impl Drop for EnvGuard {
3080 fn drop(&mut self) {
3081 unsafe {
3083 match &self.prior_project {
3084 Some(v) => std::env::set_var("VERTEXAI_PROJECT", v),
3085 None => std::env::remove_var("VERTEXAI_PROJECT"),
3086 }
3087 match &self.prior_location {
3088 Some(v) => std::env::set_var("VERTEXAI_LOCATION", v),
3089 None => std::env::remove_var("VERTEXAI_LOCATION"),
3090 }
3091 }
3092 }
3093 }
3094 let _guard = EnvGuard {
3095 prior_project,
3096 prior_location,
3097 };
3098 unsafe {
3100 std::env::set_var("VERTEXAI_PROJECT", "test-project");
3101 std::env::set_var("VERTEXAI_LOCATION", "us-central1");
3102 }
3103
3104 let config = ClientConfigBuilder::new("").load_env(false).build();
3105 assert!(
3106 config.credential_provider.is_none(),
3107 "input config should have no credential_provider"
3108 );
3109
3110 let client = DefaultClient::new(config, Some("vertex_ai/gemini-2.5-flash-lite"))
3111 .expect("DefaultClient::new should succeed for vertex with empty api_key");
3112
3113 assert!(
3114 client.config.credential_provider.is_some(),
3115 "DefaultClient::new should auto-install VertexAdcCredentialProvider for vertex_ai when no credentials are configured"
3116 );
3117 }
3118
3119 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
3122 #[test]
3123 #[serial_test::serial]
3124 fn vertex_ai_explicit_api_key_skips_auto_install() {
3125 let prior_project = std::env::var("VERTEXAI_PROJECT").ok();
3126 struct EnvGuard(Option<String>);
3127 impl Drop for EnvGuard {
3128 fn drop(&mut self) {
3129 unsafe {
3130 match &self.0 {
3131 Some(v) => std::env::set_var("VERTEXAI_PROJECT", v),
3132 None => std::env::remove_var("VERTEXAI_PROJECT"),
3133 }
3134 }
3135 }
3136 }
3137 let _guard = EnvGuard(prior_project);
3138 unsafe {
3139 std::env::set_var("VERTEXAI_PROJECT", "test-project");
3140 }
3141
3142 let config = ClientConfigBuilder::new("ya29.pre-obtained-token")
3143 .load_env(false)
3144 .build();
3145
3146 let client = DefaultClient::new(config, Some("vertex_ai/gemini-2.5-flash-lite"))
3147 .expect("DefaultClient::new should succeed");
3148
3149 assert!(
3150 client.config.credential_provider.is_none(),
3151 "auto-install must not fire when api_key is non-empty"
3152 );
3153 }
3154
3155 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
3158 #[test]
3159 #[serial_test::serial]
3160 fn vertex_ai_explicit_credential_provider_skips_auto_install() {
3161 use std::sync::Arc;
3162
3163 use crate::auth::{Credential, CredentialProvider, StaticTokenProvider};
3164 use secrecy::SecretString;
3165
3166 let prior_project = std::env::var("VERTEXAI_PROJECT").ok();
3167 struct EnvGuard(Option<String>);
3168 impl Drop for EnvGuard {
3169 fn drop(&mut self) {
3170 unsafe {
3171 match &self.0 {
3172 Some(v) => std::env::set_var("VERTEXAI_PROJECT", v),
3173 None => std::env::remove_var("VERTEXAI_PROJECT"),
3174 }
3175 }
3176 }
3177 }
3178 let _guard = EnvGuard(prior_project);
3179 unsafe {
3180 std::env::set_var("VERTEXAI_PROJECT", "test-project");
3181 }
3182
3183 let explicit: Arc<dyn CredentialProvider> =
3184 Arc::new(StaticTokenProvider::new(SecretString::from("static-token".to_owned())));
3185 let explicit_marker = Arc::as_ptr(&explicit) as *const ();
3186
3187 let config = ClientConfigBuilder::new("")
3188 .load_env(false)
3189 .credential_provider(Arc::clone(&explicit))
3190 .build();
3191
3192 let client = DefaultClient::new(config, Some("vertex_ai/gemini-2.5-flash-lite"))
3193 .expect("DefaultClient::new should succeed");
3194
3195 let installed = client
3196 .config
3197 .credential_provider
3198 .as_ref()
3199 .expect("explicit provider should survive auto-install path");
3200 let installed_marker = Arc::as_ptr(installed) as *const ();
3201 assert_eq!(
3202 installed_marker, explicit_marker,
3203 "auto-install must not overwrite an explicitly-supplied credential_provider"
3204 );
3205
3206 let rt = tokio::runtime::Builder::new_current_thread()
3207 .enable_all()
3208 .build()
3209 .expect("tokio runtime");
3210 let credential = rt.block_on(installed.resolve()).expect("resolve");
3211 match credential {
3212 Credential::BearerToken(t) => {
3213 use secrecy::ExposeSecret;
3214 assert_eq!(t.expose_secret(), "static-token");
3215 }
3216 _ => panic!("expected BearerToken"),
3217 }
3218 }
3219
3220 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
3221 fn vertex_client() -> DefaultClient {
3222 DefaultClient::new(
3223 ClientConfigBuilder::new("ya29.pre-obtained-token")
3224 .load_env(false)
3225 .build(),
3226 Some("vertex_ai/gemini-2.5-flash"),
3227 )
3228 .expect("DefaultClient::new should succeed")
3229 }
3230
3231 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
3232 struct VertexProjectGuard(Option<String>);
3233
3234 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
3235 impl VertexProjectGuard {
3236 fn set() -> Self {
3237 let prior = std::env::var("VERTEXAI_PROJECT").ok();
3238 unsafe {
3239 std::env::set_var("VERTEXAI_PROJECT", "test-project");
3240 }
3241 Self(prior)
3242 }
3243 }
3244
3245 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
3246 impl Drop for VertexProjectGuard {
3247 fn drop(&mut self) {
3248 unsafe {
3249 match &self.0 {
3250 Some(value) => std::env::set_var("VERTEXAI_PROJECT", value),
3251 None => std::env::remove_var("VERTEXAI_PROJECT"),
3252 }
3253 }
3254 }
3255 }
3256
3257 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
3262 #[test]
3263 #[serial_test::serial]
3264 fn vertex_non_streaming_request_carries_no_stream_key() {
3265 let _guard = VertexProjectGuard::set();
3266 let client = vertex_client();
3267 let req = ChatCompletionRequest {
3268 model: "vertex_ai/gemini-2.5-flash".into(),
3269 messages: vec![],
3270 ..Default::default()
3271 };
3272
3273 let prepared = client
3274 .prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(false))
3275 .expect("prepare_request should not fail");
3276
3277 assert!(
3278 prepared.body_json.get("stream").is_none(),
3279 "generateContent has no `stream` field; sending it is a hard 400, body was: {}",
3280 prepared.body_json
3281 );
3282 }
3283
3284 #[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
3287 #[test]
3288 #[serial_test::serial]
3289 fn vertex_streaming_request_carries_no_stream_key() {
3290 let _guard = VertexProjectGuard::set();
3291 let client = vertex_client();
3292 let req = ChatCompletionRequest {
3293 model: "vertex_ai/gemini-2.5-flash".into(),
3294 messages: vec![],
3295 ..Default::default()
3296 };
3297
3298 let prepared = client
3299 .prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(true))
3300 .expect("prepare_request should not fail");
3301
3302 assert!(
3303 prepared.body_json.get("stream").is_none(),
3304 "streaming is endpoint-selected on Vertex; body was: {}",
3305 prepared.body_json
3306 );
3307 }
3308
3309 #[test]
3312 fn openai_shaped_provider_still_carries_the_stream_flag() {
3313 let client = DefaultClient::new(ClientConfigBuilder::new("test-key").build(), Some("gpt-4"))
3314 .expect("client construction should succeed");
3315 let req = ChatCompletionRequest {
3316 model: "gpt-4".into(),
3317 messages: vec![],
3318 ..Default::default()
3319 };
3320
3321 for streaming in [false, true] {
3322 let prepared = client
3323 .prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(streaming))
3324 .expect("prepare_request should not fail");
3325 assert_eq!(
3326 prepared.body_json["stream"], streaming,
3327 "OpenAI-shaped bodies must keep the transport-controlled stream flag"
3328 );
3329 if streaming {
3330 assert_eq!(prepared.body_json["stream_options"]["include_usage"], true);
3331 } else {
3332 assert!(prepared.body_json.get("stream_options").is_none());
3333 }
3334 }
3335 }
3336
3337 #[test]
3338 fn caller_can_disable_default_stream_usage_request() {
3339 let client = DefaultClient::new(ClientConfigBuilder::new("test-key").build(), Some("gpt-4"))
3340 .expect("client construction should succeed");
3341 let req = ChatCompletionRequest {
3342 model: "gpt-4".into(),
3343 messages: vec![],
3344 stream_options: Some(crate::types::StreamOptions {
3345 include_usage: Some(false),
3346 }),
3347 ..Default::default()
3348 };
3349 let prepared = client
3350 .prepare_request(
3351 &req,
3352 |provider| provider.chat_completions_path(),
3353 &req.model,
3354 Some(true),
3355 )
3356 .expect("prepare_request should not fail");
3357 assert_eq!(prepared.body_json["stream_options"]["include_usage"], false);
3358 }
3359
3360 #[test]
3361 fn openai_reasoning_models_translate_max_tokens() {
3362 let client = DefaultClient::new(ClientConfigBuilder::new("test-key").build(), Some("openai/gpt-5"))
3363 .expect("client construction should succeed");
3364 let req = ChatCompletionRequest {
3365 model: "openai/gpt-5".into(),
3366 messages: vec![],
3367 max_tokens: Some(512),
3368 ..Default::default()
3369 };
3370 let prepared = client
3371 .prepare_request(
3372 &req,
3373 |provider| provider.chat_completions_path(),
3374 &req.model,
3375 Some(false),
3376 )
3377 .expect("prepare_request should not fail");
3378 assert_eq!(prepared.body_json["max_completion_tokens"], 512);
3379 assert!(prepared.body_json.get("max_tokens").is_none());
3380 }
3381
3382 #[test]
3383 fn custom_openai_compatible_endpoint_keeps_max_tokens() {
3384 let config = ClientConfigBuilder::new("test-key")
3385 .base_url("http://localhost:8080/v1")
3386 .build();
3387 let client = DefaultClient::new(config, Some("gpt-5")).expect("client construction should succeed");
3388 let req = ChatCompletionRequest {
3389 model: "gpt-5".into(),
3390 messages: vec![],
3391 max_tokens: Some(512),
3392 ..Default::default()
3393 };
3394 let prepared = client
3395 .prepare_request(
3396 &req,
3397 |provider| provider.chat_completions_path(),
3398 &req.model,
3399 Some(false),
3400 )
3401 .expect("prepare_request should not fail");
3402 assert_eq!(prepared.body_json["max_tokens"], 512);
3403 assert!(prepared.body_json.get("max_completion_tokens").is_none());
3404 }
3405
3406 #[test]
3407 fn empty_api_key_omits_authorization_header() {
3408 let config = ClientConfigBuilder::new("").load_env(false).build();
3409 let client = DefaultClient::new(config, Some("openai/gpt-4o")).expect("client construction");
3410 assert_eq!(client.cached_auth_header, None);
3411 }
3412
3413 #[test]
3414 fn anthropic_final_stream_usage_is_cumulative() {
3415 let provider = crate::provider::anthropic::AnthropicProvider::new();
3416 let cumulative = std::sync::Mutex::new(ChatStreamState::default());
3417 let start = r#"{"type":"message_start","message":{"id":"msg_1","model":"claude-sonnet-4-5","usage":{"input_tokens":10,"cache_creation_input_tokens":100,"cache_read_input_tokens":200}}}"#;
3418 let delta = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}"#;
3419
3420 parse_chat_stream_event(&provider, &cumulative, start).expect("start event");
3421 let final_chunk = parse_chat_stream_event(&provider, &cumulative, delta)
3422 .expect("delta event")
3423 .expect("final chunk");
3424 let usage = final_chunk.usage.expect("final cumulative usage");
3425 assert_eq!(usage.prompt_tokens, 310);
3426 assert_eq!(usage.completion_tokens, 7);
3427 assert_eq!(usage.total_tokens, 317);
3428 let details = usage.prompt_tokens_details.expect("cache details");
3429 assert_eq!(details.cached_tokens, 200);
3430 assert_eq!(details.cache_creation_tokens, 100);
3431 }
3432}