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
18pub const DEFAULT_VIDEO_MAX_POLL_ATTEMPTS: u32 = 120;
20pub 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 #[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
72fn 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#[derive(Debug, Clone)]
789pub struct VideoGenerationOptions {
790 pub max_poll_attempts: u32,
792 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}