Skip to main content

liter_llm/client/
mod.rs

1/// Type-state builder for [`DefaultClient`] ([`ClientBuilder`]).
2pub mod builder;
3/// Client builder configuration ([`ClientConfig`] and related helpers).
4pub mod config;
5/// On-disk client configuration schema (TOML / JSON / YAML).
6#[allow(missing_docs)]
7pub mod config_file;
8/// Canonical, binding-friendly [`LlmConfig`] and its conversion into [`ClientConfigBuilder`].
9pub mod llm_config;
10/// Tower-backed managed client wired with rate limit, cache, routing, etc.
11#[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/// Configuration for polling a batch until terminal status.
58///
59/// All time values are in seconds as `f64` so the struct bridges across FFI
60/// boundaries without requiring a `Duration` shim.
61#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
62pub struct WaitForBatchConfig {
63    /// Initial interval between polls, in seconds.
64    pub initial_interval_secs: f64,
65    /// Maximum interval between polls (backoff plateau), in seconds.
66    pub max_interval_secs: f64,
67    /// Exponential backoff multiplier (e.g., 1.5 increases delay by 50% each poll).
68    pub backoff_multiplier: f32,
69    /// Optional timeout in seconds — polling fails if this duration is exceeded.
70    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/// Error type for batch polling operations.
85///
86/// All fields use FFI-friendly types so the error can be represented across
87/// every language binding without a shim. `Duration` fields are expressed
88/// as `f64` seconds; `LiterLlmError` is flattened to `message` + `code`.
89#[derive(Debug, thiserror::Error)]
90pub enum BatchWaitError {
91    /// Batch reached a terminal failure state.
92    #[error("batch reached terminal failure state: {status:?}")]
93    Failed {
94        /// Terminal batch status (Failed, Expired, or Cancelled).
95        status: BatchStatus,
96    },
97
98    /// Polling timed out before reaching terminal status.
99    #[error("polling timed out after {timeout_secs:.1}s")]
100    Timeout {
101        /// Configured timeout in seconds.
102        timeout_secs: f64,
103    },
104
105    /// Underlying client error, flattened to `message` + numeric `code`.
106    #[error("client error (code {code}): {message}")]
107    Client {
108        /// Human-readable error description.
109        message: String,
110        /// Numeric error code (HTTP status, or 0 for non-HTTP errors).
111        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/// A boxed future returning `T`.
125#[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/// A boxed future returning `T` (WASM variant — not `Send` because JS is single-threaded).
130#[cfg(target_arch = "wasm32")]
131#[cfg_attr(alef, alef(skip))]
132pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + 'a>>;
133
134/// A boxed stream of `T`.
135#[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/// A boxed stream of `T` (WASM variant — not `Send` because JS is single-threaded).
140#[cfg(target_arch = "wasm32")]
141#[cfg_attr(alef, alef(skip))]
142pub type BoxStream<'a, T> = Pin<Box<dyn Stream<Item = T> + 'a>>;
143
144/// Result of [`DefaultClient::prepare_request`].
145///
146/// The body is pre-serialized into `bytes::Bytes` so it is serialized exactly
147/// once — the same bytes are used for signing headers and for the HTTP request
148/// body.  On retry, cloning `Bytes` is a zero-copy ref-count bump.
149///
150/// `body_json` is the pre-serialization JSON value, retained so that
151/// [`Provider::dynamic_headers`] can inspect request fields without
152/// re-parsing.
153///
154/// The `provider` is the resolved provider for this specific request — it may
155/// differ from `self.provider` when the model prefix identifies a different
156/// provider.
157#[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/// Convert an owned `(String, String)` auth header pair to `(&str, &str)` borrows.
166///
167/// Centralises the four identical `map(|(n, v)| (n.as_str(), v.as_str()))` expressions
168/// that appear wherever we hand headers to the HTTP layer.
169#[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/// Whether a provider's native chat stream protocol always ends with an event
241/// that carries a finish reason.
242///
243/// Anthropic (`message_delta` with `stop_reason`), Gemini/Vertex
244/// (`finishReason`), Cohere (`message-end`) and Bedrock (`messageStop`) all do,
245/// so a stream from them that ends without one was cut off. OpenAI-compatible
246/// servers are not held to this: some omit `[DONE]` or end with non-standard
247/// metadata events (#155), and a clean EOF there stays a normal end (#295).
248#[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/// Turns a clean end of a chat stream into [`LiterLlmError::Streaming`] when no
255/// chunk carried a finish reason, so a response truncated by a dropped
256/// connection, proxy timeout or upstream crash is not reported as complete.
257///
258/// A stream that already yielded an error is passed through unchanged.
259#[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/// Shallow-merge a top-level `"extra_body"` key into `body`, OpenAI-Python-SDK
327/// style: `{**body, **extra_body}` — extra_body keys override identically
328/// named top-level keys.
329///
330/// On the chat path, providers that consume `extra_body` themselves (Anthropic,
331/// Vertex, Bedrock) already strip it inside their own `transform_request`, so by the
332/// time this runs there is nothing left for them to merge; there it only has an
333/// effect for OpenAI-compatible providers, which pass `extra_body` through
334/// unchanged. That caveat is chat-path-only: the Responses path
335/// ([`response_request_body`]) runs no provider transform at all, so the merged keys
336/// always reach the wire as literal OpenAI Responses fields.
337///
338/// A non-object `extra_body` cannot be merged into the body root, so it is dropped
339/// with a warning rather than sent to the wire.
340#[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/// Serialize a Responses API request body with `extra_body` merged in,
362/// via [`merge_extra_body`] — the same merge semantics as the chat path.
363///
364/// The merge is the *only* thing shared with the chat path. There is deliberately no
365/// [`Provider::transform_request`] call here: the Responses body is a different wire
366/// shape from the chat body, and the provider transforms are written against the chat
367/// shape, so applying them would mangle the request rather than adapt it. The Responses
368/// path is OpenAI-only — see [`ResponseClient`](trait@ResponseClient).
369#[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/// Like [`response_request_body`], but for `create_response_stream`: forces
377/// `stream: true` in the request, both before and after the `extra_body`
378/// merge.
379#[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    // ~keep extra_body must never override `stream`: the transport path (post_json_raw vs
384    // post_stream) is chosen by the calling method, not the body, so a mismatched wire flag
385    // would desync request from response parsing.
386    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/// Human-readable JSON type name, used only for diagnostic tracing fields.
393#[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/// Core LLM client trait.
406///
407/// Provides unified access to LLM and multimodal APIs across 174 providers.
408/// Requests are routed to the correct provider based on the model name prefix
409/// (e.g. `anthropic/claude-3-5-sonnet` routes to Anthropic) or via explicit
410/// `base_url` override.
411#[cfg(not(target_arch = "wasm32"))]
412#[cfg_attr(alef, alef(skip))]
413pub trait LlmClient: Send + Sync {
414    /// Send a chat completion request.
415    ///
416    /// Routes the request to the detected provider based on the model prefix
417    /// in the `ChatCompletionRequest`. Provider-specific transformations
418    /// (request normalization, header signing) are applied automatically.
419    ///
420    /// # Errors
421    ///
422    /// Returns `LiterLlmError::BadRequest` if the model is empty.
423    /// Returns `LiterLlmError::Authentication` if credentials are missing or invalid.
424    /// Returns `LiterLlmError::Http` for network or HTTP-level errors.
425    /// Returns `LiterLlmError::ProviderError` if the provider rejects the request.
426    fn chat(&self, req: ChatCompletionRequest) -> BoxFuture<'_, Result<ChatCompletionResponse>>;
427
428    /// Send a streaming chat completion request.
429    ///
430    /// Returns a stream of `ChatCompletionChunk` items, each representing a
431    /// single token delta from the provider. The stream terminates when the
432    /// model reaches `stop_reason = "stop"` or `"length"`.
433    ///
434    /// # Errors
435    ///
436    /// Returns the same errors as [`chat`](Self::chat).
437    /// Stream errors are returned as `Err` items in the stream itself.
438    ///
439    /// # Notes
440    ///
441    /// Chunks are yielded as soon as they arrive; the stream is not buffered.
442    fn chat_stream(
443        &self,
444        req: ChatCompletionRequest,
445    ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ChatCompletionChunk>>>>;
446
447    /// Send an embedding request.
448    ///
449    /// Computes dense vector representations for semantic search, clustering, or similarity.
450    ///
451    /// # Errors
452    ///
453    /// Returns `LiterLlmError::BadRequest` if input is empty.
454    /// Returns `LiterLlmError::Http` for network errors.
455    fn embed(&self, req: EmbeddingRequest) -> BoxFuture<'_, Result<EmbeddingResponse>>;
456
457    /// List available models.
458    ///
459    /// Queries the provider's model list endpoint.
460    ///
461    /// # Errors
462    ///
463    /// Returns `LiterLlmError::Http` for network errors.
464    /// Returns `LiterLlmError::Authentication` if the API key lacks list permissions.
465    fn list_models(&self) -> BoxFuture<'_, Result<ModelsListResponse>>;
466
467    /// Generate an image.
468    ///
469    /// Creates one or more images based on a text prompt.
470    ///
471    /// # Errors
472    ///
473    /// Returns `LiterLlmError::BadRequest` if the prompt is empty.
474    /// Returns `LiterLlmError::Http` for network errors.
475    /// Returns `LiterLlmError::ProviderError` if the prompt violates content policy.
476    fn image_generate(&self, req: CreateImageRequest) -> BoxFuture<'_, Result<ImagesResponse>>;
477
478    /// Generate speech audio from text.
479    ///
480    /// Converts text to speech (TTS) using the specified voice model.
481    /// Returns raw audio bytes in the requested format.
482    ///
483    /// # Errors
484    ///
485    /// Returns `LiterLlmError::BadRequest` if text is empty.
486    /// Returns `LiterLlmError::Http` for network errors.
487    fn speech(&self, req: CreateSpeechRequest) -> BoxFuture<'_, Result<bytes::Bytes>>;
488
489    /// Transcribe audio to text.
490    ///
491    /// Converts audio files to text using automatic speech recognition (ASR).
492    ///
493    /// # Errors
494    ///
495    /// Returns `LiterLlmError::BadRequest` if the audio file is missing.
496    /// Returns `LiterLlmError::Http` for network errors.
497    fn transcribe(&self, req: CreateTranscriptionRequest) -> BoxFuture<'_, Result<TranscriptionResponse>>;
498
499    /// Check content against moderation policies.
500    ///
501    /// Evaluates text or images for potentially harmful content.
502    ///
503    /// # Errors
504    ///
505    /// Returns `LiterLlmError::BadRequest` if input is empty.
506    /// Returns `LiterLlmError::Http` for network errors.
507    fn moderate(&self, req: ModerationRequest) -> BoxFuture<'_, Result<ModerationResponse>>;
508
509    /// Rerank documents by relevance to a query.
510    ///
511    /// Orders a list of documents by their relevance to a search query.
512    ///
513    /// # Errors
514    ///
515    /// Returns `LiterLlmError::BadRequest` if query or documents are empty.
516    /// Returns `LiterLlmError::Http` for network errors.
517    fn rerank(&self, req: RerankRequest) -> BoxFuture<'_, Result<RerankResponse>>;
518
519    /// Perform a web/document search.
520    ///
521    /// Searches the web or a provider's document index for results matching the query.
522    ///
523    /// # Errors
524    ///
525    /// Returns `LiterLlmError::BadRequest` if the query is empty.
526    /// Returns `LiterLlmError::Http` for network errors.
527    fn search(&self, req: SearchRequest) -> BoxFuture<'_, Result<SearchResponse>>;
528
529    /// Extract text from a document via OCR.
530    ///
531    /// Performs optical character recognition (OCR) on images or scanned PDFs.
532    ///
533    /// # Errors
534    ///
535    /// Returns `LiterLlmError::BadRequest` if the document is missing.
536    /// Returns `LiterLlmError::Http` for network errors.
537    fn ocr(&self, req: OcrRequest) -> BoxFuture<'_, Result<OcrResponse>>;
538}
539
540/// Core LLM client trait (WASM variant — no `Send + Sync` because JS is single-threaded).
541#[cfg(target_arch = "wasm32")]
542#[cfg_attr(alef, alef(skip))]
543pub trait LlmClient {
544    /// Send a chat completion request.
545    fn chat(&self, req: ChatCompletionRequest) -> BoxFuture<'_, Result<ChatCompletionResponse>>;
546
547    /// Send a streaming chat completion request.
548    fn chat_stream(
549        &self,
550        req: ChatCompletionRequest,
551    ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ChatCompletionChunk>>>>;
552
553    /// Send an embedding request.
554    fn embed(&self, req: EmbeddingRequest) -> BoxFuture<'_, Result<EmbeddingResponse>>;
555
556    /// List available models.
557    fn list_models(&self) -> BoxFuture<'_, Result<ModelsListResponse>>;
558
559    /// Generate an image.
560    fn image_generate(&self, req: CreateImageRequest) -> BoxFuture<'_, Result<ImagesResponse>>;
561
562    /// Generate speech audio from text.
563    fn speech(&self, req: CreateSpeechRequest) -> BoxFuture<'_, Result<bytes::Bytes>>;
564
565    /// Transcribe audio to text.
566    fn transcribe(&self, req: CreateTranscriptionRequest) -> BoxFuture<'_, Result<TranscriptionResponse>>;
567
568    /// Check content against moderation policies.
569    fn moderate(&self, req: ModerationRequest) -> BoxFuture<'_, Result<ModerationResponse>>;
570
571    /// Rerank documents by relevance to a query.
572    fn rerank(&self, req: RerankRequest) -> BoxFuture<'_, Result<RerankResponse>>;
573
574    /// Perform a web/document search.
575    fn search(&self, req: SearchRequest) -> BoxFuture<'_, Result<SearchResponse>>;
576
577    /// Extract text from a document via OCR.
578    fn ocr(&self, req: OcrRequest) -> BoxFuture<'_, Result<OcrResponse>>;
579}
580
581/// Extension of [`LlmClient`] that returns raw request/response data
582/// alongside the typed response.
583///
584/// Every `_raw` method mirrors its counterpart on [`LlmClient`] but wraps the
585/// result in a [`RawExchange`] that exposes the final request body (after
586/// `transform_request`) and the raw provider response (before
587/// `transform_response`). This is useful for debugging provider-specific
588/// transformations, capturing wire-level data, or implementing custom parsing.
589#[cfg_attr(alef, alef(skip))]
590pub trait LlmClientRaw: LlmClient {
591    /// Send a chat completion request and return the raw exchange.
592    ///
593    /// The `raw_request` field contains the final JSON body sent to the
594    /// provider; `raw_response` contains the provider JSON before
595    /// normalization.
596    fn chat_raw(&self, req: ChatCompletionRequest) -> BoxFuture<'_, Result<RawExchange<ChatCompletionResponse>>>;
597
598    /// Send a streaming chat completion request and return the raw exchange.
599    ///
600    /// Only `raw_request` is available upfront — the stream itself is
601    /// returned in `stream` and consumed incrementally.
602    fn chat_stream_raw(
603        &self,
604        req: ChatCompletionRequest,
605    ) -> BoxFuture<'_, Result<RawStreamExchange<BoxStream<'static, Result<ChatCompletionChunk>>>>>;
606
607    /// Send an embedding request and return the raw exchange.
608    fn embed_raw(&self, req: EmbeddingRequest) -> BoxFuture<'_, Result<RawExchange<EmbeddingResponse>>>;
609
610    /// Generate an image and return the raw exchange.
611    fn image_generate_raw(&self, req: CreateImageRequest) -> BoxFuture<'_, Result<RawExchange<ImagesResponse>>>;
612
613    /// Transcribe audio to text and return the raw exchange.
614    fn transcribe_raw(
615        &self,
616        req: CreateTranscriptionRequest,
617    ) -> BoxFuture<'_, Result<RawExchange<TranscriptionResponse>>>;
618
619    /// Check content against moderation policies and return the raw exchange.
620    fn moderate_raw(&self, req: ModerationRequest) -> BoxFuture<'_, Result<RawExchange<ModerationResponse>>>;
621
622    /// Rerank documents by relevance to a query and return the raw exchange.
623    fn rerank_raw(&self, req: RerankRequest) -> BoxFuture<'_, Result<RawExchange<RerankResponse>>>;
624
625    /// Perform a web/document search and return the raw exchange.
626    fn search_raw(&self, req: SearchRequest) -> BoxFuture<'_, Result<RawExchange<SearchResponse>>>;
627
628    /// Extract text from a document via OCR and return the raw exchange.
629    fn ocr_raw(&self, req: OcrRequest) -> BoxFuture<'_, Result<RawExchange<OcrResponse>>>;
630}
631
632/// File management operations (upload, list, retrieve, delete).
633#[cfg(not(target_arch = "wasm32"))]
634#[cfg_attr(alef, alef(skip))]
635pub trait FileClient: Send + Sync {
636    /// Upload a file.
637    fn create_file(&self, req: CreateFileRequest) -> BoxFuture<'_, Result<FileObject>>;
638
639    /// Retrieve metadata for a file.
640    fn retrieve_file(&self, file_id: &str) -> BoxFuture<'_, Result<FileObject>>;
641
642    /// Delete a file.
643    fn delete_file(&self, file_id: &str) -> BoxFuture<'_, Result<DeleteResponse>>;
644
645    /// List files, optionally filtered by query parameters.
646    fn list_files(&self, query: Option<FileListQuery>) -> BoxFuture<'_, Result<FileListResponse>>;
647
648    /// Retrieve the raw content of a file.
649    fn file_content(&self, file_id: &str) -> BoxFuture<'_, Result<bytes::Bytes>>;
650}
651
652/// File management operations (upload, list, retrieve, delete) (WASM variant).
653#[cfg(target_arch = "wasm32")]
654#[cfg_attr(alef, alef(skip))]
655pub trait FileClient {
656    /// Upload a file.
657    fn create_file(&self, req: CreateFileRequest) -> BoxFuture<'_, Result<FileObject>>;
658
659    /// Retrieve metadata for a file.
660    fn retrieve_file(&self, file_id: &str) -> BoxFuture<'_, Result<FileObject>>;
661
662    /// Delete a file.
663    fn delete_file(&self, file_id: &str) -> BoxFuture<'_, Result<DeleteResponse>>;
664
665    /// List files, optionally filtered by query parameters.
666    fn list_files(&self, query: Option<FileListQuery>) -> BoxFuture<'_, Result<FileListResponse>>;
667
668    /// Retrieve the raw content of a file.
669    fn file_content(&self, file_id: &str) -> BoxFuture<'_, Result<bytes::Bytes>>;
670}
671
672/// Batch processing operations (create, list, retrieve, cancel).
673#[cfg(not(target_arch = "wasm32"))]
674#[cfg_attr(alef, alef(skip))]
675pub trait BatchClient: Send + Sync {
676    /// Create a new batch job.
677    fn create_batch(&self, req: CreateBatchRequest) -> BoxFuture<'_, Result<BatchObject>>;
678
679    /// Retrieve a batch by ID.
680    fn retrieve_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>>;
681
682    /// List batches, optionally filtered by query parameters.
683    fn list_batches(&self, query: Option<BatchListQuery>) -> BoxFuture<'_, Result<BatchListResponse>>;
684
685    /// Cancel an in-progress batch.
686    fn cancel_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>>;
687}
688
689/// Batch processing operations (create, list, retrieve, cancel) (WASM variant).
690#[cfg(target_arch = "wasm32")]
691#[cfg_attr(alef, alef(skip))]
692pub trait BatchClient {
693    /// Create a new batch job.
694    fn create_batch(&self, req: CreateBatchRequest) -> BoxFuture<'_, Result<BatchObject>>;
695
696    /// Retrieve a batch by ID.
697    fn retrieve_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>>;
698
699    /// List batches, optionally filtered by query parameters.
700    fn list_batches(&self, query: Option<BatchListQuery>) -> BoxFuture<'_, Result<BatchListResponse>>;
701
702    /// Cancel an in-progress batch.
703    fn cancel_batch(&self, batch_id: &str) -> BoxFuture<'_, Result<BatchObject>>;
704}
705
706/// OpenAI Responses API operations (create, retrieve, cancel).
707///
708/// # Provider support: OpenAI only
709///
710/// Every other client trait in this module is provider-agnostic — requests go through
711/// a shared `prepare_request` step that resolves the provider from the model prefix,
712/// strips that prefix, and applies [`Provider::transform_request`] /
713/// [`Provider::transform_response`]. **This trait does none of that.** The
714/// implementation sends [`CreateResponseRequest`] to
715/// `{base_url}` + [`Provider::responses_path`] verbatim and deserializes the reply
716/// straight into [`ResponseObject`], so both directions assume the OpenAI Responses
717/// wire format.
718///
719/// Consequently:
720///
721/// - No provider overrides [`Provider::responses_path`], and no entry in
722///   `schemas/providers.json` declares a `responses` endpoint — provider Responses
723///   support is not modeled anywhere in the registry.
724/// - Requests stay pinned to the provider the client was constructed with. A
725///   `provider/model` prefix in [`CreateResponseRequest::model`] is neither stripped
726///   nor used for routing; it travels to the wire intact.
727/// - Providers that rewrite the request or normalize the response for chat (Anthropic,
728///   Vertex, Bedrock, Cohere, Google AI) get no such chance here. Their transforms are
729///   written against the Chat Completions body shape and would corrupt a Responses body,
730///   which is why they are not applied rather than merely forgotten.
731/// - Bedrock, Vertex, and Google AI embed the model into `build_url`, but only for the
732///   `chat/completions` and `embeddings` paths they special-case; `responses_path()` falls
733///   through to their plain `{base}{endpoint_path}` branch, so the empty model passed here is
734///   harmless for them. Azure embeds the model unconditionally, so the empty model yields
735///   `.../openai/deployments//responses` — an empty path segment. The four call sites below
736///   detect that shape via `reject_malformed_responses_url` and fail fast with
737///   [`LiterLlmError::EndpointNotSupported`] instead of sending it.
738///
739/// Use these methods with OpenAI, or with a gateway that serves OpenAI's `/responses`
740/// contract natively. For cross-provider work use [`LlmClient::chat`].
741#[cfg(not(target_arch = "wasm32"))]
742#[cfg_attr(alef, alef(skip))]
743pub trait ResponseClient: Send + Sync {
744    /// Create a new response.
745    fn create_response(&self, req: CreateResponseRequest) -> BoxFuture<'_, Result<ResponseObject>>;
746
747    /// Create a new response and stream its SSE events as they arrive.
748    ///
749    /// Returns a stream of `ResponseStreamEvent` items. The stream terminates
750    /// when a terminal event (`response.completed`, `response.incomplete`, or
751    /// `response.failed`) is yielded and the underlying connection closes.
752    ///
753    /// # Errors
754    ///
755    /// Returns the same errors as [`create_response`](Self::create_response).
756    /// Stream errors are returned as `Err` items in the stream itself.
757    fn create_response_stream(
758        &self,
759        req: CreateResponseRequest,
760    ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ResponseStreamEvent>>>>;
761
762    /// Retrieve a response by ID.
763    fn retrieve_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>>;
764
765    /// Cancel an in-progress response.
766    fn cancel_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>>;
767}
768
769/// OpenAI Responses API operations (create, retrieve, cancel) (WASM variant).
770///
771/// # Provider support: OpenAI only
772///
773/// See the non-WASM `ResponseClient` for the full rationale. In short: this path sends
774/// `CreateResponseRequest` verbatim and parses the reply as the OpenAI Responses wire
775/// format. It applies no provider request/response transform, performs no model-prefix
776/// routing, and no provider in `schemas/providers.json` declares a `responses`
777/// endpoint. Use the chat path for cross-provider work.
778#[cfg(target_arch = "wasm32")]
779#[cfg_attr(alef, alef(skip))]
780pub trait ResponseClient {
781    /// Create a new response.
782    fn create_response(&self, req: CreateResponseRequest) -> BoxFuture<'_, Result<ResponseObject>>;
783
784    /// Create a new response and stream its SSE events as they arrive.
785    ///
786    /// Returns a stream of `ResponseStreamEvent` items. The stream terminates
787    /// when a terminal event (`response.completed`, `response.incomplete`, or
788    /// `response.failed`) is yielded and the underlying connection closes.
789    ///
790    /// # Errors
791    ///
792    /// Returns the same errors as [`create_response`](Self::create_response).
793    /// Stream errors are returned as `Err` items in the stream itself.
794    fn create_response_stream(
795        &self,
796        req: CreateResponseRequest,
797    ) -> BoxFuture<'_, Result<BoxStream<'static, Result<ResponseStreamEvent>>>>;
798
799    /// Retrieve a response by ID.
800    fn retrieve_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>>;
801
802    /// Cancel an in-progress response.
803    fn cancel_response(&self, response_id: &str) -> BoxFuture<'_, Result<ResponseObject>>;
804}
805
806/// Default client implementation backed by `reqwest`.
807///
808/// Sends requests to 174 LLM providers with automatic provider detection
809/// and per-request routing. The provider is resolved at construction time
810/// from `model_hint` (or defaults to OpenAI), but individual requests can
811/// override the provider via model name prefix (e.g. `"anthropic/claude-3-5-sonnet"`
812/// routes to Anthropic regardless of construction-time setting).
813///
814/// When the model prefix does not match any known provider, the construction-time
815/// provider is used as the fallback. This enables seamless migration between
816/// providers by changing only the model name.
817///
818/// The provider is stored behind an [`Arc`] so it can be shared cheaply into
819/// async closures and streaming tasks. Pre-computed auth headers and extra
820/// headers are cached at construction to avoid redundant encoding on every request.
821#[cfg(any(feature = "native-http", feature = "wasm-http"))]
822#[derive(Clone)]
823pub struct DefaultClient {
824    config: ClientConfig,
825    http: reqwest::Client,
826    /// Provider resolved at construction; shared via Arc so streaming closures
827    /// can capture an owned reference without requiring `unsafe`.
828    provider: Arc<dyn Provider>,
829    /// Pre-computed auth header `(name, value)` — avoids `format!("Bearer {key}")`
830    /// on every request.  `None` when the provider requires no authentication.
831    cached_auth_header: Option<(String, String)>,
832    /// Pre-computed static extra headers — avoids converting `&'static str` pairs
833    /// to `(String, String)` on every request.
834    cached_extra_headers: Vec<(String, String)>,
835    /// Tower middleware stack installed by [`create_client_from_json`](crate::create_client_from_json).
836    ///
837    /// ~keep The managed client wraps a separate `DefaultClient` whose own `managed` is `None`,
838    /// so delegation never recurses.
839    #[cfg_attr(not(all(feature = "native-http", feature = "tower")), allow(dead_code))]
840    managed: Option<ManagedHandle>,
841}
842
843/// Handle to the managed middleware stack; uninhabited when the stack is not compiled in.
844#[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    /// Route the [`LlmClient`] methods through `managed`'s middleware stack.
855    #[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    /// Build a client.
869    ///
870    /// Constructs an HTTP client with the given configuration and provider hint.
871    /// If `model_hint` is provided, its prefix determines the default provider
872    /// (e.g. `"groq/llama3-70b"` selects Groq; `"claude-3-5-sonnet"` defaults to OpenAI).
873    /// Pass `None` to use OpenAI as the default. The hint does not constrain
874    /// per-request routing — individual requests can override the provider via
875    /// their own model prefix.
876    ///
877    /// When `config.load_env` is true (the default), and no API key was provided,
878    /// the client reads the provider's designated environment variable
879    /// (e.g. `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) at construction time.
880    ///
881    /// # Errors
882    ///
883    /// Returns `LiterLlmError::Authentication` if `load_env` is enabled, no explicit
884    /// API key was provided, and the provider's environment variable is unset or empty.
885    /// Returns `LiterLlmError::InvalidHeader` if pre-validated headers somehow
886    /// become invalid during client construction (extremely rare; indicates a bug).
887    /// Returns `LiterLlmError::Http` if the underlying HTTP client cannot be constructed.
888    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        // ~keep Auto-install Vertex ADC only when Vertex AI has no explicit api_key or credential_provider.
914        // ~keep Explicit tokens and credential providers stay authoritative over Workload Identity defaults.
915        #[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    /// Resolve the provider for a specific request based on the model string.
968    ///
969    /// If the model prefix clearly identifies a provider that differs from the
970    /// construction-time default, the detected provider is returned.  Otherwise
971    /// the construction-time provider is reused (zero allocation).
972    fn resolve_provider_for_model(&self, model: &str) -> Arc<dyn Provider> {
973        // ~keep A base_url override pins the construction-time provider for mock/proxy endpoints.
974        if self.config.base_url.is_some() {
975            return Arc::clone(&self.provider);
976        }
977        // ~keep Keep the construction-time provider when it already matches the model.
978        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    /// Compute the auth header for a given provider (potentially different from
991    /// the construction-time cached one).
992    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            // ~keep Auth headers must be recomputed after per-request provider resolution.
1008            Ok((provider_auth_header(prov, self.config.api_key.expose_secret()), None))
1009        }
1010    }
1011
1012    /// Build the combined header list for a request using a specific provider.
1013    ///
1014    /// # Errors
1015    ///
1016    /// Propagates a signing failure from [`Provider::signing_headers`] (e.g. an
1017    /// internal Bedrock SigV4 error) instead of sending an unsigned request.
1018    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    /// Shared helper: resolve the per-request provider, build the URL, strip
1038    /// model prefix from the request body, set the `stream` flag, apply provider
1039    /// transform, and return everything needed to fire a request.
1040    ///
1041    /// `endpoint_fn` receives the resolved provider and returns the endpoint
1042    /// path (e.g. `|p| p.chat_completions_path()`), ensuring the path comes
1043    /// from the correct provider when per-request routing overrides the default.
1044    ///
1045    /// `stream` is inserted into the body **before** `transform_request` runs,
1046    /// so providers can inspect the final body state in one pass.
1047    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        // ~keep Providers such as Azure and Bedrock embed model/deployment identifiers in URLs.
1064        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        // ~keep Whether the provider's wire format carries `stream` at all is the provider's
1087        // decision, recorded here by whether the key survived `transform_request`. Providers that
1088        // rebuild the body into a native format (Vertex/Gemini `generateContent`, Bedrock
1089        // `converse`) drop it deliberately -- those APIs select streaming by endpoint and reject
1090        // the field outright ("Unknown name \"stream\": Cannot find field", HTTP 400). Providers
1091        // whose format does carry it (OpenAI-compatible, Anthropic, Cohere) keep it.
1092        let provider_wire_carries_stream = body.get("stream").is_some();
1093        merge_extra_body(&mut body);
1094        // ~keep extra_body must never override `stream`: the transport path
1095        // (post_json_raw vs post_stream) is chosen by the calling method, not the
1096        // body, so a mismatched wire flag would desync request from response parsing.
1097        // Restoring it is therefore scoped to providers that had it after the transform.
1098        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        // ~keep Serialize once so signing bytes and request body bytes are identical.
1106        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    /// Resolve the auth header for a request using the construction-time provider.
1117    ///
1118    /// Uses the pre-computed cached auth header for efficiency.  When a
1119    /// [`CredentialProvider`] is configured, it is called to obtain a fresh
1120    /// credential which overrides the cached header.
1121    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    /// Build the combined header list using the construction-time provider.
1137    ///
1138    /// Uses pre-computed cached extra headers for efficiency.
1139    ///
1140    /// # Errors
1141    ///
1142    /// Propagates a signing failure from [`Provider::signing_headers`] (e.g. an
1143    /// internal Bedrock SigV4 error) instead of sending an unsigned request.
1144    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"))]
1172/// Resolve the provider to use for all requests on this client.
1173///
1174/// Priority:
1175/// 1. Explicit `base_url` in config:
1176///    - If `model_hint` identifies a provider with a non-standard URL format
1177///      (Azure embeds the deployment name and `?api-version=…`), construct
1178///      that provider with the override (issue #83).
1179///    - Otherwise, treat the override as a generic OpenAI-compatible endpoint
1180///      (LM Studio, Ollama, vLLM, gateways, etc.).  Models are sent verbatim
1181///      except for a leading `custom/` and a leading `X/` where `X` is the
1182///      provider named by `model_hint` (issue #249).
1183/// 2. `model_hint` -> auto-detect by model name prefix.
1184/// 3. Default -> OpenAI.
1185fn 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/// Build a [`provider::bedrock::BedrockProvider`] from `config`, threading
1234/// through any explicit region, cross-region prefix, and credentials set on
1235/// [`ClientConfig`]. Falls back to the environment for anything left unset,
1236/// matching [`provider::bedrock::BedrockProvider::from_env`].
1237#[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            // ~keep Pass stream=false so providers can transform non-streaming chat correctly.
1263            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            // ~keep Prepare first for validation/transforms, then override with provider stream URL.
1305            let prepared = self.prepare_request(&req, |p| p.chat_completions_path(), &req.model, Some(true))?;
1306
1307            // ~keep Streaming endpoints can differ from non-streaming provider URLs.
1308            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            // ~keep Embeddings have no stream flag; passing None prevents inserting one.
1380            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            // ~keep list_models has no model string, so use the construction-time provider.
1417            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/// Internal trait for batch retrieval, used to abstract polling logic for testability.
2373///
2374/// Method name avoids collision with the inherent `DefaultClient::retrieve_batch`
2375/// so alef-generated bindings don't need to import this trait into scope.
2376#[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    /// Retrieve a batch by ID for polling purposes.
2383    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/// Poll a batch until it reaches a terminal status.
2396///
2397/// This is the internal implementation shared by tests and `DefaultClient::wait_for_batch`.
2398#[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    /// Poll a batch until it reaches a terminal status (Completed, Failed, Expired, Cancelled).
2442    ///
2443    /// Uses exponential backoff with configurable initial interval, maximum interval, and backoff multiplier.
2444    /// Optionally supports a timeout that aborts polling if exceeded.
2445    ///
2446    /// # Errors
2447    ///
2448    /// Returns `BatchWaitError::Failed` if the batch reaches a failure terminal status.
2449    /// Returns `BatchWaitError::Timeout` if the configured timeout is exceeded.
2450    /// Returns `BatchWaitError::Client` for underlying client errors.
2451    ///
2452    /// # Example
2453    ///
2454    /// ```no_run
2455    /// # use liter_llm::client::{DefaultClient, ClientConfig, WaitForBatchConfig};
2456    /// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
2457    /// let client = DefaultClient::new(ClientConfig::new("api-key"), None)?;
2458    /// let batch = client.wait_for_batch("b-123", WaitForBatchConfig::default()).await?;
2459    /// println!("Batch completed: {:?}", batch.status);
2460    /// # Ok(())
2461    /// # }
2462    /// ```
2463    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/// Parse a single Responses API SSE `data:` payload into a [`ResponseStreamEvent`].
2473///
2474/// Unlike [`Provider::parse_stream_event`], this has no provider dispatch: it always
2475/// decodes the OpenAI Responses `response.*` event vocabulary. That is not evidence
2476/// that the format is portable — the Responses path is OpenAI-only (see
2477/// [`ResponseClient`](trait@ResponseClient)), so there is no second format to dispatch
2478/// on. Unrecognized `type` values decode into
2479/// [`ResponseStreamEvent::Unknown`] rather than failing (see
2480/// `ResponseStreamEvent`'s `Deserialize` impl), so this only returns `Err`
2481/// for payloads that are not valid JSON at all.
2482#[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/// Reject a Responses URL that contains an empty path segment (`//`).
2492///
2493/// The four `ResponseClient` call sites pass `""` as the model to
2494/// [`Provider::build_url`] / [`Provider::build_stream_url`], because none of `create_response`,
2495/// `retrieve_response`, or `cancel_response` has a model available to give the URL-embedding
2496/// providers (see the `ResponseClient` trait doc). Most providers' `build_url` ignores the model
2497/// for the `/responses` path — it only fires for `chat/completions` and `embeddings` — so an
2498/// empty model is harmless there. Azure's `build_url` embeds the model unconditionally, so an
2499/// empty model turns into an empty path segment: `.../openai/deployments//responses`.
2500///
2501/// This checks the URL shape rather than `provider.name()` on purpose: naming a specific
2502/// provider would either allow-list `"openai"` and reject supported OpenAI-compatible gateways
2503/// running under a different provider name, or deny-list `"azure"` and silently miss any future
2504/// provider with the same URL-embedding bug. Checking for the empty segment catches exactly the
2505/// malformed case, from whichever provider produces it, and never fires for a provider whose
2506/// `build_url` output is well-formed. ~keep
2507///
2508/// One other configuration can trip this, so do not read the error as "this provider cannot do
2509/// Responses" without checking: [`ClientConfig::base_url`] is a public field, so a value set
2510/// directly rather than through [`ClientConfigBuilder::base_url`] or
2511/// [`ClientConfig::with_base_url`] skips their `trim_end_matches('/')`, and a trailing slash
2512/// then yields `.../v1//responses` here too. That configuration is already broken for every
2513/// other endpoint as well, so it should surface long before this check — but if this fires for
2514/// a provider that ought to work, inspect `base_url` before suspecting the provider. ~keep
2515#[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            // ~keep Force streaming on the wire regardless of what the caller (or extra_body) set.
2562            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    /// Revert line: reverting `reject_malformed_responses_url` in `crates/liter-llm/src/client/
2707    /// mod.rs` to always return `Ok(())` makes this test fail — it would no longer catch the
2708    /// empty `/openai/deployments//responses` segment Azure produces when no deployment name
2709    /// (model) is available, which is the case at all four `ResponseClient` call sites.
2710    #[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    /// Azure base URLs that already pin a deployment (`/openai/deployments/{name}`) never reach
2734    /// the model-embedding branch of `AzureProvider::build_url`, so the produced URL has no empty
2735    /// segment and must not be rejected.
2736    #[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    /// Bedrock, Vertex, and Google AI embed the model into `build_url` only for the
2754    /// `chat/completions` and `embeddings` paths; `responses_path()` matches neither, so the
2755    /// empty model passed at the four `ResponseClient` call sites must not be rejected for them.
2756    #[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    /// When the resolved provider is Vertex AI and the caller supplied neither
3063    /// an explicit `api_key` nor a `credential_provider`, `DefaultClient::new`
3064    /// auto-installs `VertexAdcCredentialProvider`. This is the canonical auth
3065    /// path for GKE / Workload Identity deployments where short-lived OAuth2
3066    /// tokens come from the metadata server.
3067    #[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        // ~keep SAFETY: serial_test::serial prevents concurrent env mutation.
3072        // ~keep Prior values are restored by the guard below.
3073        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                // ~keep SAFETY: single-threaded restoration during test teardown.
3082                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        // ~keep SAFETY: serial_test::serial ensures exclusive env access.
3099        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    /// When the caller already supplied an `api_key`, the auto-install does
3120    /// not fire — pre-obtained tokens take precedence over ADC discovery.
3121    #[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    /// When the caller explicitly supplied a `credential_provider`, the
3156    /// auto-install does not overwrite it.
3157    #[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    /// Vertex/Gemini rebuilds the body into native `generateContent` shape, which has no
3258    /// `stream` field. Restoring the transport flag after `transform_request` therefore shipped
3259    /// `"stream": false` on every non-streaming call, and Vertex rejects the whole request with
3260    /// HTTP 400 `Invalid JSON payload received. Unknown name "stream": Cannot find field.`
3261    #[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    /// Streaming against Vertex is selected by endpoint (`streamGenerateContent`), not by a body
3285    /// flag, so the streaming path must not reintroduce the key either.
3286    #[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    /// Guard against over-correcting: providers whose wire format does carry `stream` must still
3310    /// get the transport-controlled flag, so the request cannot desync from the response parser.
3311    #[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}