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