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_responses_sse_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    /// Raw OpenAI-format tool_calls array, surfaced unchanged on `ChatResponse`.
42    /// Optional because non-tool-using replies omit it entirely.
43    #[serde(default)]
44    tool_calls: Option<Value>,
45}
46
47#[derive(Debug, Deserialize)]
48struct OpenAIUsage {
49    prompt_tokens: Option<u64>,
50    completion_tokens: Option<u64>,
51    total_tokens: Option<u64>,
52    cost: Option<Value>,
53    completion_tokens_details: Option<CompletionTokensDetails>,
54}
55
56#[derive(Debug, Deserialize)]
57struct CompletionTokensDetails {
58    reasoning_tokens: Option<u64>,
59}
60
61#[derive(Debug, Deserialize)]
62struct OpenAIEmbeddingResponse {
63    data: Vec<OpenAIEmbeddingItem>,
64    usage: Option<OpenAIUsage>,
65}
66
67#[derive(Debug, Deserialize)]
68struct OpenAIEmbeddingItem {
69    embedding: Vec<f32>,
70}
71
72/// Build the chat request body from a ChatRequest.
73///
74/// This is shared between streaming and non-streaming chat calls.
75fn build_chat_body(req: &ChatRequest, stream: bool) -> Result<Value> {
76    let mut body = serde_json::json!({
77        "model": req.model,
78        "messages": req.messages,
79    });
80
81    if stream {
82        body["stream"] = serde_json::json!(true);
83    }
84
85    if let Some(temp) = req.temperature {
86        body["temperature"] = serde_json::json!(temp);
87    }
88    if let Some(max_tokens) = req.max_tokens {
89        body["max_tokens"] = serde_json::json!(max_tokens);
90    }
91    if let Some(ref fmt) = req.response_format {
92        body["response_format"] = fmt.clone();
93    }
94    if let Some(max_completion_tokens) = req.max_completion_tokens {
95        body["max_completion_tokens"] = serde_json::json!(max_completion_tokens);
96    }
97    if let Some(ref tools) = req.tools {
98        body["tools"] = tools.clone();
99    }
100    if let Some(ref tool_choice) = req.tool_choice {
101        body["tool_choice"] = tool_choice.clone();
102    }
103    if let Some(parallel) = req.parallel_tool_calls {
104        body["parallel_tool_calls"] = serde_json::json!(parallel);
105    }
106    if let Some(ref stop) = req.stop {
107        body["stop"] = stop.clone();
108    }
109    if let Some(top_p) = req.top_p {
110        body["top_p"] = serde_json::json!(top_p);
111    }
112    if let Some(presence) = req.presence_penalty {
113        body["presence_penalty"] = serde_json::json!(presence);
114    }
115    if let Some(frequency) = req.frequency_penalty {
116        body["frequency_penalty"] = serde_json::json!(frequency);
117    }
118    if let Some(seed) = req.seed {
119        body["seed"] = serde_json::json!(seed);
120    }
121    if let Some(ref user) = req.user {
122        body["user"] = serde_json::json!(user);
123    }
124    if let Some(ref metadata) = req.metadata {
125        body["metadata"] = metadata.clone();
126    }
127    if let Some(ref reasoning_effort) = req.reasoning_effort {
128        body["reasoning_effort"] = reasoning_effort.clone();
129    }
130    if let Some(ref thinking) = req.thinking {
131        body["thinking"] = thinking.clone();
132    }
133
134    merge_extra_body(
135        &mut body,
136        req.extra_body.as_ref(),
137        &[
138            "model",
139            "messages",
140            "stream",
141            "temperature",
142            "max_tokens",
143            "response_format",
144            "max_completion_tokens",
145            "tools",
146            "tool_choice",
147            "parallel_tool_calls",
148            "stop",
149            "top_p",
150            "presence_penalty",
151            "frequency_penalty",
152            "seed",
153            "user",
154            "metadata",
155            "reasoning_effort",
156            "thinking",
157        ],
158    )?;
159
160    Ok(body)
161}
162
163fn is_astra(model: &str) -> bool {
164    model == "gpt-6-astra" || model.starts_with("gpt-6-astra-")
165}
166
167fn responses_content(role: &str, content: &crate::types::ChatMessageContent) -> Result<Value> {
168    match content {
169        crate::types::ChatMessageContent::Text(text) => Ok(Value::String(text.clone())),
170        crate::types::ChatMessageContent::Parts(parts) => {
171            let mapped = parts
172                .iter()
173                .map(|part| match part {
174                    crate::types::ChatContentPart::Text(text) => {
175                        let kind = if role == "assistant" {
176                            "output_text"
177                        } else {
178                            "input_text"
179                        };
180                        Ok(serde_json::json!({ "type": kind, "text": text.text }))
181                    }
182                    crate::types::ChatContentPart::ImageUrl(image) => {
183                        let image_url = match &image.image_url {
184                            crate::types::ChatImageUrl::Url(url) => url.clone(),
185                            crate::types::ChatImageUrl::Object(object) => object.url.clone(),
186                        };
187                        Ok(serde_json::json!({ "type": "input_image", "image_url": image_url }))
188                    }
189                    other => Err(LiteLLMError::Unsupported(format!(
190                        "Responses input does not support chat content part: {other:?}"
191                    ))),
192                })
193                .collect::<Result<Vec<_>>>()?;
194            Ok(Value::Array(mapped))
195        }
196    }
197}
198
199fn responses_input(req: &ChatRequest) -> Result<Vec<Value>> {
200    let mut input = Vec::new();
201    for message in &req.messages {
202        if message.role == "tool" {
203            let call_id = message.tool_call_id.as_deref().ok_or_else(|| {
204                LiteLLMError::Config("Responses tool messages require tool_call_id".into())
205            })?;
206            let output = match &message.content {
207                crate::types::ChatMessageContent::Text(text) => text.clone(),
208                other => serde_json::to_string(other)
209                    .map_err(|err| LiteLLMError::Parse(err.to_string()))?,
210            };
211            input.push(serde_json::json!({
212                "type": "function_call_output",
213                "call_id": call_id,
214                "output": output,
215            }));
216            continue;
217        }
218
219        input.push(serde_json::json!({
220            "role": message.role,
221            "content": responses_content(&message.role, &message.content)?,
222        }));
223
224        if let Some(tool_calls) = message.tool_calls.as_ref().and_then(Value::as_array) {
225            for call in tool_calls {
226                let function =
227                    call.get("function")
228                        .and_then(Value::as_object)
229                        .ok_or_else(|| {
230                            LiteLLMError::Config("invalid Chat Completions tool call".into())
231                        })?;
232                input.push(serde_json::json!({
233                    "type": "function_call",
234                    "call_id": call.get("id").and_then(Value::as_str).ok_or_else(|| LiteLLMError::Config("tool call requires id".into()))?,
235                    "name": function.get("name").and_then(Value::as_str).ok_or_else(|| LiteLLMError::Config("tool call requires function.name".into()))?,
236                    "arguments": function.get("arguments").and_then(Value::as_str).ok_or_else(|| LiteLLMError::Config("tool call requires function.arguments".into()))?,
237                }));
238            }
239        }
240    }
241    Ok(input)
242}
243
244fn responses_tools(tools: &Value) -> Result<Value> {
245    let tools = tools
246        .as_array()
247        .ok_or_else(|| LiteLLMError::Config("tools must be an array".into()))?;
248    let mapped = tools
249        .iter()
250        .map(|tool| {
251            if tool.get("type").and_then(Value::as_str) != Some("function") {
252                return Ok(tool.clone());
253            }
254            let function = tool
255                .get("function")
256                .and_then(Value::as_object)
257                .ok_or_else(|| {
258                    LiteLLMError::Config("function tool requires function object".into())
259                })?;
260            let mut mapped = serde_json::Map::new();
261            mapped.insert("type".into(), Value::String("function".into()));
262            for key in ["name", "description", "parameters", "strict"] {
263                if let Some(value) = function.get(key) {
264                    mapped.insert(key.into(), value.clone());
265                }
266            }
267            if !mapped.contains_key("name") {
268                return Err(LiteLLMError::Config("function tool requires name".into()));
269            }
270            Ok(Value::Object(mapped))
271        })
272        .collect::<Result<Vec<_>>>()?;
273    Ok(Value::Array(mapped))
274}
275
276fn responses_text_format(format: &Value) -> Result<Value> {
277    if format.get("type").and_then(Value::as_str) == Some("json_schema") {
278        let schema = format
279            .get("json_schema")
280            .and_then(Value::as_object)
281            .ok_or_else(|| {
282                LiteLLMError::Config("json_schema response format requires json_schema".into())
283            })?;
284        let mut flattened = schema.clone();
285        flattened.insert("type".into(), Value::String("json_schema".into()));
286        Ok(Value::Object(flattened))
287    } else {
288        Ok(format.clone())
289    }
290}
291
292fn build_responses_body(req: &ChatRequest, stream: bool) -> Result<Value> {
293    let astra = is_astra(&req.model);
294    if req.max_tokens.is_some() {
295        return Err(LiteLLMError::Unsupported(
296            "Responses API uses max_completion_tokens, not max_tokens".into(),
297        ));
298    }
299    for (name, value) in [
300        ("stop", req.stop.as_ref()),
301        (
302            "presence_penalty",
303            req.presence_penalty.as_ref().map(|_| &Value::Null),
304        ),
305        (
306            "frequency_penalty",
307            req.frequency_penalty.as_ref().map(|_| &Value::Null),
308        ),
309        ("seed", req.seed.as_ref().map(|_| &Value::Null)),
310        ("thinking", req.thinking.as_ref()),
311    ] {
312        if value.is_some() {
313            return Err(LiteLLMError::Unsupported(format!(
314                "{name} is not mapped by the Responses transport"
315            )));
316        }
317    }
318
319    let mut body = serde_json::json!({
320        "model": req.model,
321        "input": responses_input(req)?,
322    });
323    if stream {
324        body["stream"] = Value::Bool(true);
325    }
326    if let Some(max_tokens) = req.max_completion_tokens {
327        body["max_output_tokens"] = serde_json::json!(max_tokens);
328    }
329    if !astra {
330        if let Some(temperature) = req.temperature {
331            body["temperature"] = serde_json::json!(temperature);
332        }
333        if let Some(top_p) = req.top_p {
334            body["top_p"] = serde_json::json!(top_p);
335        }
336    }
337    if let Some(format) = &req.response_format {
338        body["text"] = serde_json::json!({ "format": responses_text_format(format)? });
339    }
340    if let Some(tools) = &req.tools {
341        body["tools"] = responses_tools(tools)?;
342    }
343    if let Some(tool_choice) = &req.tool_choice {
344        body["tool_choice"] = match tool_choice {
345            Value::Object(choice)
346                if choice.get("type").and_then(Value::as_str) == Some("function")
347                    && choice.get("function").is_some() =>
348            {
349                let function = choice
350                    .get("function")
351                    .and_then(Value::as_object)
352                    .ok_or_else(|| {
353                        LiteLLMError::Config("function tool_choice requires function object".into())
354                    })?;
355                serde_json::json!({
356                    "type": "function",
357                    "name": function.get("name").ok_or_else(|| LiteLLMError::Config("function tool_choice requires name".into()))?
358                })
359            }
360            value => value.clone(),
361        };
362    }
363    if let Some(parallel) = req.parallel_tool_calls {
364        body["parallel_tool_calls"] = Value::Bool(parallel);
365    }
366    if let Some(metadata) = &req.metadata {
367        body["metadata"] = metadata.clone();
368    }
369    if let Some(user) = &req.user {
370        body["user"] = Value::String(user.clone());
371    }
372    if let Some(effort) = &req.reasoning_effort {
373        let effort = if astra && matches!(effort.as_str(), Some("none" | "minimal")) {
374            Value::String("low".into())
375        } else {
376            effort.clone()
377        };
378        body["reasoning"] = serde_json::json!({ "effort": effort });
379    }
380    merge_extra_body(
381        &mut body,
382        req.extra_body.as_ref(),
383        &[
384            "model",
385            "input",
386            "stream",
387            "max_output_tokens",
388            "text",
389            "tools",
390            "tool_choice",
391            "parallel_tool_calls",
392            "metadata",
393            "user",
394            "reasoning",
395            "temperature",
396            "top_p",
397            "top_logprobs",
398        ],
399    )?;
400    Ok(body)
401}
402
403pub async fn responses_chat(
404    client: &Client,
405    cfg: &ProviderConfig,
406    req: ChatRequest,
407) -> Result<ChatResponse> {
408    let base = cfg
409        .base_url
410        .clone()
411        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
412    let key = resolve_api_key(cfg)?;
413    let body = build_responses_body(&req, false)?;
414    let mut builder = client
415        .post(format!("{}/responses", base.trim_end_matches('/')))
416        .json(&body);
417    if let Some(key) = key {
418        builder = builder.bearer_auth(key);
419    }
420    for (k, v) in &cfg.extra_headers {
421        builder = builder.header(k, v);
422    }
423    let (raw, headers) = send_json::<Value>(builder).await?;
424    let content = raw
425        .get("output")
426        .and_then(Value::as_array)
427        .into_iter()
428        .flatten()
429        .filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
430        .flat_map(|item| {
431            item.get("content")
432                .and_then(Value::as_array)
433                .into_iter()
434                .flatten()
435        })
436        .filter(|part| part.get("type").and_then(Value::as_str) == Some("output_text"))
437        .filter_map(|part| part.get("text").and_then(Value::as_str))
438        .collect::<String>();
439    let usage = raw
440        .get("usage")
441        .map(|usage| Usage {
442            prompt_tokens: usage.get("input_tokens").and_then(Value::as_u64),
443            completion_tokens: usage.get("output_tokens").and_then(Value::as_u64),
444            thoughts_tokens: usage
445                .pointer("/output_tokens_details/reasoning_tokens")
446                .and_then(Value::as_u64),
447            total_tokens: usage.get("total_tokens").and_then(Value::as_u64),
448            cost_usd: usage.get("cost").and_then(Value::as_f64),
449            ..Default::default()
450        })
451        .unwrap_or_default();
452    let refusal = raw
453        .get("output")
454        .and_then(Value::as_array)
455        .into_iter()
456        .flatten()
457        .filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
458        .flat_map(|item| {
459            item.get("content")
460                .and_then(Value::as_array)
461                .into_iter()
462                .flatten()
463        })
464        .filter(|part| part.get("type").and_then(Value::as_str) == Some("refusal"))
465        .filter_map(|part| part.get("refusal").and_then(Value::as_str))
466        .collect::<String>();
467    if !refusal.is_empty() {
468        return Err(LiteLLMError::Refusal {
469            text: refusal,
470            usage: Box::new(usage),
471        });
472    }
473    if matches!(raw.get("status").and_then(Value::as_str), Some("failed")) {
474        let message = raw
475            .pointer("/error/message")
476            .and_then(Value::as_str)
477            .unwrap_or("Responses API failed to complete the response");
478        return Err(LiteLLMError::http(message));
479    }
480    if matches!(
481        raw.get("status").and_then(Value::as_str),
482        Some("incomplete")
483    ) {
484        let reason = raw
485            .pointer("/incomplete_details/reason")
486            .and_then(Value::as_str)
487            .unwrap_or("Responses API returned an incomplete response");
488        return Err(LiteLLMError::Truncated {
489            text: if content.is_empty() {
490                reason.to_string()
491            } else {
492                content.clone()
493            },
494            usage: Box::new(usage),
495        });
496    }
497    let calls: Vec<Value> = raw.get("output").and_then(Value::as_array).into_iter().flatten()
498        .filter(|item| item.get("type").and_then(Value::as_str) == Some("function_call"))
499        .map(|item| serde_json::json!({
500            "id": item.get("call_id").or_else(|| item.get("id")).cloned().unwrap_or(Value::Null),
501            "type": "function",
502            "function": {
503                "name": item.get("name").cloned().unwrap_or(Value::Null),
504                "arguments": item.get("arguments").cloned().unwrap_or_else(|| Value::String(String::new())),
505            }
506        })).collect();
507    let response_id = raw.get("id").and_then(Value::as_str).map(str::to_owned);
508    let header_cost = headers
509        .get("x-litellm-response-cost")
510        .and_then(|v| v.to_str().ok())
511        .and_then(|v| v.parse().ok());
512    Ok(ChatResponse {
513        content,
514        reasoning: None,
515        tool_calls: (!calls.is_empty()).then_some(Value::Array(calls)),
516        usage,
517        response_id,
518        header_cost,
519        raw: Some(raw),
520    })
521}
522
523pub async fn responses_chat_stream(
524    client: &Client,
525    cfg: &ProviderConfig,
526    req: ChatRequest,
527) -> Result<ChatStream> {
528    let base = cfg
529        .base_url
530        .clone()
531        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
532    let key = resolve_api_key(cfg)?;
533    let body = build_responses_body(&req, true)?;
534    let mut builder = client
535        .post(format!("{}/responses", base.trim_end_matches('/')))
536        .json(&body);
537    if let Some(key) = key {
538        builder = builder.bearer_auth(key);
539    }
540    for (k, v) in &cfg.extra_headers {
541        builder = builder.header(k, v);
542    }
543    let resp = builder.send().await.map_err(LiteLLMError::from)?;
544    let status = resp.status();
545    if !status.is_success() {
546        return Err(LiteLLMError::http(format!(
547            "http {}: {}",
548            status.as_u16(),
549            resp.text().await.map_err(LiteLLMError::from)?
550        )));
551    }
552    Ok(parse_responses_sse_stream(resp.bytes_stream()))
553}
554
555pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Result<ChatResponse> {
556    let base = cfg
557        .base_url
558        .clone()
559        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
560    let url = format!("{}/chat/completions", base.trim_end_matches('/'));
561    let key = resolve_api_key(cfg)?;
562
563    let body = build_chat_body(&req, false)?;
564
565    let mut builder = client.post(url).json(&body);
566    if let Some(key) = key {
567        builder = builder.bearer_auth(key);
568    }
569    for (k, v) in &cfg.extra_headers {
570        builder = builder.header(k, v);
571    }
572
573    let (parsed, headers) = send_json::<OpenAIChatResponse>(builder).await?;
574    let first_message = parsed.choices.first().map(|c| &c.message);
575    let content = first_message
576        .and_then(|message| extract_text_value(message.content.as_ref()))
577        .unwrap_or_default();
578    let reasoning = first_message.and_then(extract_reasoning);
579    let tool_calls = first_message
580        .and_then(|m| m.tool_calls.clone())
581        .filter(|v| !matches!(v, Value::Null) && !matches!(v, Value::Array(a) if a.is_empty()));
582    let header_cost = headers
583        .get("x-litellm-response-cost")
584        .and_then(|v| v.to_str().ok())
585        .and_then(|v| v.parse::<f64>().ok());
586    let mut usage = map_usage(parsed.usage);
587    if usage.cost_usd.is_none() {
588        usage.cost_usd = header_cost;
589    }
590
591    Ok(ChatResponse {
592        content,
593        reasoning,
594        tool_calls,
595        usage,
596        response_id: parsed.id,
597        header_cost,
598        raw: None,
599    })
600}
601
602fn extract_reasoning(message: &OpenAIMessage) -> Option<Reasoning> {
603    message
604        .reasoning_details
605        .as_ref()
606        .and_then(|details| details.as_array().cloned())
607        .and_then(Reasoning::from_details)
608        .or_else(|| {
609            message
610                .reasoning_content
611                .as_ref()
612                .or(message.reasoning.as_ref())
613                .cloned()
614                .and_then(Reasoning::from_text)
615        })
616}
617
618fn extract_text_value(value: Option<&Value>) -> Option<String> {
619    let mut out = String::new();
620    collect_text_fragments(value?, &mut out);
621    (!out.trim().is_empty()).then_some(out)
622}
623
624fn collect_text_fragments(value: &Value, out: &mut String) {
625    match value {
626        Value::String(text) => out.push_str(text),
627        Value::Array(items) => {
628            for item in items {
629                collect_text_fragments(item, out);
630            }
631        }
632        Value::Object(map) => {
633            if let Some(text) = map.get("text") {
634                collect_text_fragments(text, out);
635            } else if let Some(content) = map.get("content") {
636                collect_text_fragments(content, out);
637            } else if let Some(value) = map.get("value") {
638                collect_text_fragments(value, out);
639            }
640        }
641        _ => {}
642    }
643}
644
645pub async fn chat_stream(
646    client: &Client,
647    cfg: &ProviderConfig,
648    req: ChatRequest,
649) -> Result<ChatStream> {
650    let base = cfg
651        .base_url
652        .clone()
653        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
654    let url = format!("{}/chat/completions", base.trim_end_matches('/'));
655    let key = resolve_api_key(cfg)?;
656
657    let body = build_chat_body(&req, true)?;
658
659    let mut builder = client.post(url).json(&body);
660    if let Some(key) = key {
661        builder = builder.bearer_auth(key);
662    }
663    for (k, v) in &cfg.extra_headers {
664        builder = builder.header(k, v);
665    }
666
667    let resp = builder.send().await.map_err(LiteLLMError::from)?;
668    let status = resp.status();
669    if !status.is_success() {
670        let text = resp.text().await.map_err(LiteLLMError::from)?;
671        return Err(LiteLLMError::http(format!(
672            "http {}: {}",
673            status.as_u16(),
674            text
675        )));
676    }
677
678    Ok(parse_sse_stream(resp.bytes_stream()))
679}
680
681pub async fn embeddings(
682    client: &Client,
683    cfg: &ProviderConfig,
684    req: EmbeddingRequest,
685) -> Result<EmbeddingResponse> {
686    let base = cfg
687        .base_url
688        .clone()
689        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
690    let url = format!("{}/embeddings", base.trim_end_matches('/'));
691    let key = resolve_api_key(cfg)?;
692
693    let mut body = serde_json::json!({
694        "model": req.model,
695        "input": req.input,
696    });
697    merge_extra_body(&mut body, req.extra_body.as_ref(), &["model", "input"])?;
698
699    let mut builder = client.post(url).json(&body);
700    if let Some(key) = key {
701        builder = builder.bearer_auth(key);
702    }
703    for (k, v) in &cfg.extra_headers {
704        builder = builder.header(k, v);
705    }
706
707    let (parsed, _headers) = send_json::<OpenAIEmbeddingResponse>(builder).await?;
708    let vectors = parsed.data.into_iter().map(|d| d.embedding).collect();
709
710    Ok(EmbeddingResponse {
711        vectors,
712        usage: map_usage(parsed.usage),
713        raw: None,
714    })
715}
716
717pub async fn image_generation(
718    client: &Client,
719    cfg: &ProviderConfig,
720    req: ImageRequest,
721) -> Result<ImageResponse> {
722    let base = cfg
723        .base_url
724        .clone()
725        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
726    let url = format!("{}/images/generations", base.trim_end_matches('/'));
727    let key = resolve_api_key(cfg)?;
728
729    let mut body = serde_json::json!({
730        "model": req.model,
731        "prompt": req.prompt,
732    });
733    if let Some(n) = req.n {
734        body["n"] = serde_json::json!(n);
735    }
736    if let Some(ref size) = req.size {
737        body["size"] = serde_json::json!(size);
738    }
739    if let Some(ref quality) = req.quality {
740        body["quality"] = serde_json::json!(quality);
741    }
742    if let Some(ref background) = req.background {
743        body["background"] = serde_json::json!(background);
744    }
745    merge_extra_body(&mut body, req.extra_body.as_ref(), &["model", "prompt"])?;
746
747    let mut builder = client.post(url).json(&body);
748    if let Some(key) = key {
749        builder = builder.bearer_auth(key);
750    }
751    for (k, v) in &cfg.extra_headers {
752        builder = builder.header(k, v);
753    }
754
755    let (parsed, _headers) = send_json::<Value>(builder).await?;
756    let images = parsed
757        .get("data")
758        .and_then(|v| v.as_array())
759        .map(|arr| {
760            arr.iter()
761                .map(|item| ImageData {
762                    b64_json: item
763                        .get("b64_json")
764                        .and_then(|v| v.as_str())
765                        .map(|s| s.to_string()),
766                    url: item
767                        .get("url")
768                        .and_then(|v| v.as_str())
769                        .map(|s| s.to_string()),
770                    revised_prompt: item
771                        .get("revised_prompt")
772                        .and_then(|v| v.as_str())
773                        .map(|s| s.to_string()),
774                    mime_type: None,
775                })
776                .collect::<Vec<_>>()
777        })
778        .unwrap_or_default();
779
780    Ok(ImageResponse {
781        images,
782        usage: Usage::default(),
783        raw: None,
784    })
785}
786
787/// Video generation options for configurable timeouts.
788#[derive(Debug, Clone)]
789pub struct VideoGenerationOptions {
790    /// Maximum number of polling attempts
791    pub max_poll_attempts: u32,
792    /// Interval between polling attempts in seconds
793    pub poll_interval_secs: u64,
794}
795
796impl Default for VideoGenerationOptions {
797    fn default() -> Self {
798        Self {
799            max_poll_attempts: DEFAULT_VIDEO_MAX_POLL_ATTEMPTS,
800            poll_interval_secs: DEFAULT_VIDEO_POLL_INTERVAL_SECS,
801        }
802    }
803}
804
805pub async fn video_generation(
806    client: &Client,
807    cfg: &ProviderConfig,
808    req: VideoRequest,
809) -> Result<VideoResponse> {
810    video_generation_with_options(client, cfg, req, VideoGenerationOptions::default()).await
811}
812
813pub async fn video_generation_with_options(
814    client: &Client,
815    cfg: &ProviderConfig,
816    req: VideoRequest,
817    options: VideoGenerationOptions,
818) -> Result<VideoResponse> {
819    let base = cfg
820        .base_url
821        .clone()
822        .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
823    let url = format!("{}/videos", base.trim_end_matches('/'));
824    let key = resolve_api_key(cfg)?;
825
826    let mut form = Form::new()
827        .text("model", req.model)
828        .text("prompt", req.prompt);
829    if let Some(seconds) = req.seconds {
830        form = form.text("seconds", seconds.to_string());
831    }
832    if let Some(size) = req.size {
833        form = form.text("size", size);
834    }
835
836    let mut builder = client.post(url).multipart(form);
837    if let Some(ref key) = key {
838        builder = builder.bearer_auth(key.clone());
839    }
840    for (k, v) in &cfg.extra_headers {
841        builder = builder.header(k, v);
842    }
843
844    let (parsed, _headers) = send_json::<Value>(builder).await?;
845    let video_id = parsed
846        .get("id")
847        .and_then(|v| v.as_str())
848        .ok_or_else(|| LiteLLMError::Parse("missing video id".into()))?;
849
850    let status_url = format!("{}/videos/{}", base.trim_end_matches('/'), video_id);
851    let poll_interval = Duration::from_secs(options.poll_interval_secs);
852
853    for attempt in 0..options.max_poll_attempts {
854        let mut status_builder = client.get(&status_url);
855        if let Some(ref key) = key {
856            status_builder = status_builder.bearer_auth(key.clone());
857        }
858        let (status_resp, _headers) = send_json::<Value>(status_builder).await?;
859        let status = status_resp
860            .get("status")
861            .and_then(|v| v.as_str())
862            .unwrap_or("unknown");
863
864        match status {
865            "completed" => {
866                return fetch_video_content(client, &base, video_id, key.as_deref()).await;
867            }
868            "failed" => {
869                let msg = status_resp
870                    .get("error")
871                    .and_then(|v| v.as_str())
872                    .unwrap_or("video generation failed");
873                return Err(LiteLLMError::http(msg.to_string()));
874            }
875            _ => {
876                if attempt + 1 >= options.max_poll_attempts {
877                    return Err(LiteLLMError::http(format!(
878                        "video generation timed out after {} attempts",
879                        options.max_poll_attempts
880                    )));
881                }
882                sleep(poll_interval).await;
883            }
884        }
885    }
886
887    Err(LiteLLMError::http("video generation timed out"))
888}
889
890async fn fetch_video_content(
891    client: &Client,
892    base: &str,
893    video_id: &str,
894    key: Option<&str>,
895) -> Result<VideoResponse> {
896    let content_url = format!("{}/videos/{}/content", base.trim_end_matches('/'), video_id);
897    let mut content_builder = client.get(&content_url);
898    if let Some(key) = key {
899        content_builder = content_builder.bearer_auth(key);
900    }
901
902    let bytes = content_builder
903        .send()
904        .await
905        .map_err(LiteLLMError::from)?
906        .bytes()
907        .await
908        .map_err(LiteLLMError::from)?;
909    let b64 = general_purpose::STANDARD.encode(bytes);
910
911    Ok(VideoResponse {
912        video_url: Some(format!("data:video/mp4;base64,{b64}")),
913        raw: None,
914    })
915}
916
917fn map_usage(usage: Option<OpenAIUsage>) -> Usage {
918    usage.map_or_else(Usage::default, |u| Usage {
919        prompt_tokens: u.prompt_tokens,
920        completion_tokens: u.completion_tokens,
921        thoughts_tokens: u.completion_tokens_details.and_then(|d| d.reasoning_tokens),
922        total_tokens: u.total_tokens,
923        cost_usd: parse_cost(u.cost.as_ref()),
924        ..Default::default()
925    })
926}
927
928fn parse_cost(value: Option<&Value>) -> Option<f64> {
929    let v = value?;
930    if let Some(n) = v.as_f64() {
931        return Some(n);
932    }
933    if let Some(s) = v.as_str() {
934        return s.parse::<f64>().ok();
935    }
936    None
937}