Skip to main content

litellm_rust/providers/
openai_compat.rs

1use crate::config::ProviderConfig;
2use crate::error::{LiteLLMError, Result};
3use crate::http::send_json;
4use crate::providers::resolve_api_key;
5use crate::stream::{parse_sse_stream, ChatStream};
6use crate::types::{
7    ChatRequest, ChatResponse, EmbeddingRequest, EmbeddingResponse, ImageData, ImageRequest,
8    ImageResponse, Usage, VideoRequest, VideoResponse,
9};
10use base64::{engine::general_purpose, Engine as _};
11use reqwest::multipart::Form;
12use reqwest::Client;
13use serde::Deserialize;
14use serde_json::Value;
15use std::time::Duration;
16use tokio::time::sleep;
17
18/// Default maximum polling attempts for video generation (120 * 5s = 10 minutes)
19pub const DEFAULT_VIDEO_MAX_POLL_ATTEMPTS: u32 = 120;
20/// Default polling interval for video generation status checks
21pub const DEFAULT_VIDEO_POLL_INTERVAL_SECS: u64 = 5;
22
23#[derive(Debug, Deserialize)]
24struct OpenAIChatResponse {
25    id: Option<String>,
26    choices: Vec<OpenAIChoice>,
27    usage: Option<OpenAIUsage>,
28}
29
30#[derive(Debug, Deserialize)]
31struct OpenAIChoice {
32    message: OpenAIMessage,
33}
34
35#[derive(Debug, Deserialize)]
36struct OpenAIMessage {
37    content: Option<Value>,
38    reasoning: Option<String>,
39    reasoning_details: Option<Value>,
40}
41
42#[derive(Debug, Deserialize)]
43struct OpenAIUsage {
44    prompt_tokens: Option<u64>,
45    completion_tokens: Option<u64>,
46    total_tokens: Option<u64>,
47    cost: Option<Value>,
48    completion_tokens_details: Option<CompletionTokensDetails>,
49}
50
51#[derive(Debug, Deserialize)]
52struct CompletionTokensDetails {
53    reasoning_tokens: Option<u64>,
54}
55
56#[derive(Debug, Deserialize)]
57struct OpenAIEmbeddingResponse {
58    data: Vec<OpenAIEmbeddingItem>,
59    usage: Option<OpenAIUsage>,
60}
61
62#[derive(Debug, Deserialize)]
63struct OpenAIEmbeddingItem {
64    embedding: Vec<f32>,
65}
66
67/// Build the chat request body from a ChatRequest.
68///
69/// This is shared between streaming and non-streaming chat calls.
70fn build_chat_body(req: &ChatRequest, stream: bool) -> Value {
71    let mut body = serde_json::json!({
72        "model": req.model,
73        "messages": req.messages,
74    });
75
76    if stream {
77        body["stream"] = serde_json::json!(true);
78    }
79
80    if let Some(temp) = req.temperature {
81        body["temperature"] = serde_json::json!(temp);
82    }
83    if let Some(max_tokens) = req.max_tokens {
84        body["max_tokens"] = serde_json::json!(max_tokens);
85    }
86    if let Some(ref fmt) = req.response_format {
87        body["response_format"] = fmt.clone();
88    }
89    if let Some(max_completion_tokens) = req.max_completion_tokens {
90        body["max_completion_tokens"] = serde_json::json!(max_completion_tokens);
91    }
92    if let Some(ref tools) = req.tools {
93        body["tools"] = tools.clone();
94    }
95    if let Some(ref tool_choice) = req.tool_choice {
96        body["tool_choice"] = tool_choice.clone();
97    }
98    if let Some(parallel) = req.parallel_tool_calls {
99        body["parallel_tool_calls"] = serde_json::json!(parallel);
100    }
101    if let Some(ref stop) = req.stop {
102        body["stop"] = stop.clone();
103    }
104    if let Some(top_p) = req.top_p {
105        body["top_p"] = serde_json::json!(top_p);
106    }
107    if let Some(presence) = req.presence_penalty {
108        body["presence_penalty"] = serde_json::json!(presence);
109    }
110    if let Some(frequency) = req.frequency_penalty {
111        body["frequency_penalty"] = serde_json::json!(frequency);
112    }
113    if let Some(seed) = req.seed {
114        body["seed"] = serde_json::json!(seed);
115    }
116    if let Some(ref user) = req.user {
117        body["user"] = serde_json::json!(user);
118    }
119    if let Some(ref metadata) = req.metadata {
120        body["metadata"] = metadata.clone();
121    }
122    if let Some(ref reasoning_effort) = req.reasoning_effort {
123        body["reasoning_effort"] = reasoning_effort.clone();
124    }
125    if let Some(ref thinking) = req.thinking {
126        body["thinking"] = thinking.clone();
127    }
128
129    body
130}
131
132pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Result<ChatResponse> {
133    let base = cfg
134        .base_url
135        .clone()
136        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
137    let url = format!("{}/chat/completions", base.trim_end_matches('/'));
138    let key = resolve_api_key(cfg)?;
139
140    let body = build_chat_body(&req, false);
141
142    let mut builder = client.post(url).json(&body);
143    if let Some(key) = key {
144        builder = builder.bearer_auth(key);
145    }
146    for (k, v) in &cfg.extra_headers {
147        builder = builder.header(k, v);
148    }
149
150    let (parsed, headers) = send_json::<OpenAIChatResponse>(builder).await?;
151    let content = parsed
152        .choices
153        .first()
154        .map(|c| extract_message_text(&c.message))
155        .unwrap_or_default();
156    let header_cost = headers
157        .get("x-litellm-response-cost")
158        .and_then(|v| v.to_str().ok())
159        .and_then(|v| v.parse::<f64>().ok());
160    let mut usage = map_usage(parsed.usage);
161    if usage.cost_usd.is_none() {
162        usage.cost_usd = header_cost;
163    }
164
165    Ok(ChatResponse {
166        content,
167        usage,
168        response_id: parsed.id,
169        header_cost,
170        raw: None,
171    })
172}
173
174fn extract_message_text(message: &OpenAIMessage) -> String {
175    extract_text_value(message.content.as_ref())
176        .or_else(|| {
177            message
178                .reasoning
179                .as_ref()
180                .filter(|text| !text.trim().is_empty())
181                .cloned()
182        })
183        .or_else(|| extract_text_value(message.reasoning_details.as_ref()))
184        .unwrap_or_default()
185}
186
187fn extract_text_value(value: Option<&Value>) -> Option<String> {
188    let mut out = String::new();
189    collect_text_fragments(value?, &mut out);
190    (!out.trim().is_empty()).then_some(out)
191}
192
193fn collect_text_fragments(value: &Value, out: &mut String) {
194    match value {
195        Value::String(text) => out.push_str(text),
196        Value::Array(items) => {
197            for item in items {
198                collect_text_fragments(item, out);
199            }
200        }
201        Value::Object(map) => {
202            if let Some(text) = map.get("text") {
203                collect_text_fragments(text, out);
204            } else if let Some(content) = map.get("content") {
205                collect_text_fragments(content, out);
206            } else if let Some(value) = map.get("value") {
207                collect_text_fragments(value, out);
208            }
209        }
210        _ => {}
211    }
212}
213
214pub async fn chat_stream(
215    client: &Client,
216    cfg: &ProviderConfig,
217    req: ChatRequest,
218) -> Result<ChatStream> {
219    let base = cfg
220        .base_url
221        .clone()
222        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
223    let url = format!("{}/chat/completions", base.trim_end_matches('/'));
224    let key = resolve_api_key(cfg)?;
225
226    let body = build_chat_body(&req, true);
227
228    let mut builder = client.post(url).json(&body);
229    if let Some(key) = key {
230        builder = builder.bearer_auth(key);
231    }
232    for (k, v) in &cfg.extra_headers {
233        builder = builder.header(k, v);
234    }
235
236    let resp = builder.send().await.map_err(LiteLLMError::from)?;
237    let status = resp.status();
238    if !status.is_success() {
239        let text = resp.text().await.map_err(LiteLLMError::from)?;
240        return Err(LiteLLMError::http(format!(
241            "http {}: {}",
242            status.as_u16(),
243            text
244        )));
245    }
246
247    Ok(parse_sse_stream(resp.bytes_stream()))
248}
249
250pub async fn embeddings(
251    client: &Client,
252    cfg: &ProviderConfig,
253    req: EmbeddingRequest,
254) -> Result<EmbeddingResponse> {
255    let base = cfg
256        .base_url
257        .clone()
258        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
259    let url = format!("{}/embeddings", base.trim_end_matches('/'));
260    let key = resolve_api_key(cfg)?;
261
262    let body = serde_json::json!({
263        "model": req.model,
264        "input": req.input,
265    });
266
267    let mut builder = client.post(url).json(&body);
268    if let Some(key) = key {
269        builder = builder.bearer_auth(key);
270    }
271    for (k, v) in &cfg.extra_headers {
272        builder = builder.header(k, v);
273    }
274
275    let (parsed, _headers) = send_json::<OpenAIEmbeddingResponse>(builder).await?;
276    let vectors = parsed.data.into_iter().map(|d| d.embedding).collect();
277
278    Ok(EmbeddingResponse {
279        vectors,
280        usage: map_usage(parsed.usage),
281        raw: None,
282    })
283}
284
285pub async fn image_generation(
286    client: &Client,
287    cfg: &ProviderConfig,
288    req: ImageRequest,
289) -> Result<ImageResponse> {
290    let base = cfg
291        .base_url
292        .clone()
293        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
294    let url = format!("{}/images/generations", base.trim_end_matches('/'));
295    let key = resolve_api_key(cfg)?;
296
297    let mut body = serde_json::json!({
298        "model": req.model,
299        "prompt": req.prompt,
300    });
301    if let Some(n) = req.n {
302        body["n"] = serde_json::json!(n);
303    }
304    if let Some(ref size) = req.size {
305        body["size"] = serde_json::json!(size);
306    }
307    if let Some(ref quality) = req.quality {
308        body["quality"] = serde_json::json!(quality);
309    }
310    if let Some(ref background) = req.background {
311        body["background"] = serde_json::json!(background);
312    }
313
314    let mut builder = client.post(url).json(&body);
315    if let Some(key) = key {
316        builder = builder.bearer_auth(key);
317    }
318    for (k, v) in &cfg.extra_headers {
319        builder = builder.header(k, v);
320    }
321
322    let (parsed, _headers) = send_json::<Value>(builder).await?;
323    let images = parsed
324        .get("data")
325        .and_then(|v| v.as_array())
326        .map(|arr| {
327            arr.iter()
328                .map(|item| ImageData {
329                    b64_json: item
330                        .get("b64_json")
331                        .and_then(|v| v.as_str())
332                        .map(|s| s.to_string()),
333                    url: item
334                        .get("url")
335                        .and_then(|v| v.as_str())
336                        .map(|s| s.to_string()),
337                    revised_prompt: item
338                        .get("revised_prompt")
339                        .and_then(|v| v.as_str())
340                        .map(|s| s.to_string()),
341                    mime_type: None,
342                })
343                .collect::<Vec<_>>()
344        })
345        .unwrap_or_default();
346
347    Ok(ImageResponse {
348        images,
349        usage: Usage::default(),
350        raw: None,
351    })
352}
353
354/// Video generation options for configurable timeouts.
355#[derive(Debug, Clone)]
356pub struct VideoGenerationOptions {
357    /// Maximum number of polling attempts
358    pub max_poll_attempts: u32,
359    /// Interval between polling attempts in seconds
360    pub poll_interval_secs: u64,
361}
362
363impl Default for VideoGenerationOptions {
364    fn default() -> Self {
365        Self {
366            max_poll_attempts: DEFAULT_VIDEO_MAX_POLL_ATTEMPTS,
367            poll_interval_secs: DEFAULT_VIDEO_POLL_INTERVAL_SECS,
368        }
369    }
370}
371
372pub async fn video_generation(
373    client: &Client,
374    cfg: &ProviderConfig,
375    req: VideoRequest,
376) -> Result<VideoResponse> {
377    video_generation_with_options(client, cfg, req, VideoGenerationOptions::default()).await
378}
379
380pub async fn video_generation_with_options(
381    client: &Client,
382    cfg: &ProviderConfig,
383    req: VideoRequest,
384    options: VideoGenerationOptions,
385) -> Result<VideoResponse> {
386    let base = cfg
387        .base_url
388        .clone()
389        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
390    let url = format!("{}/videos", base.trim_end_matches('/'));
391    let key = resolve_api_key(cfg)?;
392
393    let mut form = Form::new()
394        .text("model", req.model)
395        .text("prompt", req.prompt);
396    if let Some(seconds) = req.seconds {
397        form = form.text("seconds", seconds.to_string());
398    }
399    if let Some(size) = req.size {
400        form = form.text("size", size);
401    }
402
403    let mut builder = client.post(url).multipart(form);
404    if let Some(ref key) = key {
405        builder = builder.bearer_auth(key.clone());
406    }
407    for (k, v) in &cfg.extra_headers {
408        builder = builder.header(k, v);
409    }
410
411    let (parsed, _headers) = send_json::<Value>(builder).await?;
412    let video_id = parsed
413        .get("id")
414        .and_then(|v| v.as_str())
415        .ok_or_else(|| LiteLLMError::Parse("missing video id".into()))?;
416
417    let status_url = format!("{}/videos/{}", base.trim_end_matches('/'), video_id);
418    let poll_interval = Duration::from_secs(options.poll_interval_secs);
419
420    for attempt in 0..options.max_poll_attempts {
421        let mut status_builder = client.get(&status_url);
422        if let Some(ref key) = key {
423            status_builder = status_builder.bearer_auth(key.clone());
424        }
425        let (status_resp, _headers) = send_json::<Value>(status_builder).await?;
426        let status = status_resp
427            .get("status")
428            .and_then(|v| v.as_str())
429            .unwrap_or("unknown");
430
431        match status {
432            "completed" => {
433                return fetch_video_content(client, &base, video_id, key.as_deref()).await;
434            }
435            "failed" => {
436                let msg = status_resp
437                    .get("error")
438                    .and_then(|v| v.as_str())
439                    .unwrap_or("video generation failed");
440                return Err(LiteLLMError::http(msg.to_string()));
441            }
442            _ => {
443                if attempt + 1 >= options.max_poll_attempts {
444                    return Err(LiteLLMError::http(format!(
445                        "video generation timed out after {} attempts",
446                        options.max_poll_attempts
447                    )));
448                }
449                sleep(poll_interval).await;
450            }
451        }
452    }
453
454    Err(LiteLLMError::http("video generation timed out"))
455}
456
457async fn fetch_video_content(
458    client: &Client,
459    base: &str,
460    video_id: &str,
461    key: Option<&str>,
462) -> Result<VideoResponse> {
463    let content_url = format!("{}/videos/{}/content", base.trim_end_matches('/'), video_id);
464    let mut content_builder = client.get(&content_url);
465    if let Some(key) = key {
466        content_builder = content_builder.bearer_auth(key);
467    }
468
469    let bytes = content_builder
470        .send()
471        .await
472        .map_err(LiteLLMError::from)?
473        .bytes()
474        .await
475        .map_err(LiteLLMError::from)?;
476    let b64 = general_purpose::STANDARD.encode(bytes);
477
478    Ok(VideoResponse {
479        video_url: Some(format!("data:video/mp4;base64,{b64}")),
480        raw: None,
481    })
482}
483
484fn map_usage(usage: Option<OpenAIUsage>) -> Usage {
485    usage.map_or_else(Usage::default, |u| Usage {
486        prompt_tokens: u.prompt_tokens,
487        completion_tokens: u.completion_tokens,
488        thoughts_tokens: u.completion_tokens_details.and_then(|d| d.reasoning_tokens),
489        total_tokens: u.total_tokens,
490        cost_usd: parse_cost(u.cost.as_ref()),
491    })
492}
493
494fn parse_cost(value: Option<&Value>) -> Option<f64> {
495    let v = value?;
496    if let Some(n) = v.as_f64() {
497        return Some(n);
498    }
499    if let Some(s) = v.as_str() {
500        return s.parse::<f64>().ok();
501    }
502    None
503}