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 })
450 .unwrap_or_default();
451 let refusal = raw
452 .get("output")
453 .and_then(Value::as_array)
454 .into_iter()
455 .flatten()
456 .filter(|item| item.get("type").and_then(Value::as_str) == Some("message"))
457 .flat_map(|item| {
458 item.get("content")
459 .and_then(Value::as_array)
460 .into_iter()
461 .flatten()
462 })
463 .filter(|part| part.get("type").and_then(Value::as_str) == Some("refusal"))
464 .filter_map(|part| part.get("refusal").and_then(Value::as_str))
465 .collect::<String>();
466 if !refusal.is_empty() {
467 return Err(LiteLLMError::Refusal {
468 text: refusal,
469 usage,
470 });
471 }
472 if matches!(raw.get("status").and_then(Value::as_str), Some("failed")) {
473 let message = raw
474 .pointer("/error/message")
475 .and_then(Value::as_str)
476 .unwrap_or("Responses API failed to complete the response");
477 return Err(LiteLLMError::http(message));
478 }
479 if matches!(
480 raw.get("status").and_then(Value::as_str),
481 Some("incomplete")
482 ) {
483 let reason = raw
484 .pointer("/incomplete_details/reason")
485 .and_then(Value::as_str)
486 .unwrap_or("Responses API returned an incomplete response");
487 return Err(LiteLLMError::Truncated {
488 text: if content.is_empty() {
489 reason.to_string()
490 } else {
491 content.clone()
492 },
493 usage,
494 });
495 }
496 let calls: Vec<Value> = raw.get("output").and_then(Value::as_array).into_iter().flatten()
497 .filter(|item| item.get("type").and_then(Value::as_str) == Some("function_call"))
498 .map(|item| serde_json::json!({
499 "id": item.get("call_id").or_else(|| item.get("id")).cloned().unwrap_or(Value::Null),
500 "type": "function",
501 "function": {
502 "name": item.get("name").cloned().unwrap_or(Value::Null),
503 "arguments": item.get("arguments").cloned().unwrap_or_else(|| Value::String(String::new())),
504 }
505 })).collect();
506 let response_id = raw.get("id").and_then(Value::as_str).map(str::to_owned);
507 let header_cost = headers
508 .get("x-litellm-response-cost")
509 .and_then(|v| v.to_str().ok())
510 .and_then(|v| v.parse().ok());
511 Ok(ChatResponse {
512 content,
513 reasoning: None,
514 tool_calls: (!calls.is_empty()).then_some(Value::Array(calls)),
515 usage,
516 response_id,
517 header_cost,
518 raw: Some(raw),
519 })
520}
521
522pub async fn responses_chat_stream(
523 client: &Client,
524 cfg: &ProviderConfig,
525 req: ChatRequest,
526) -> Result<ChatStream> {
527 let base = cfg
528 .base_url
529 .clone()
530 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
531 let key = resolve_api_key(cfg)?;
532 let body = build_responses_body(&req, true)?;
533 let mut builder = client
534 .post(format!("{}/responses", base.trim_end_matches('/')))
535 .json(&body);
536 if let Some(key) = key {
537 builder = builder.bearer_auth(key);
538 }
539 for (k, v) in &cfg.extra_headers {
540 builder = builder.header(k, v);
541 }
542 let resp = builder.send().await.map_err(LiteLLMError::from)?;
543 let status = resp.status();
544 if !status.is_success() {
545 return Err(LiteLLMError::http(format!(
546 "http {}: {}",
547 status.as_u16(),
548 resp.text().await.map_err(LiteLLMError::from)?
549 )));
550 }
551 Ok(parse_responses_sse_stream(resp.bytes_stream()))
552}
553
554pub async fn chat(client: &Client, cfg: &ProviderConfig, req: ChatRequest) -> Result<ChatResponse> {
555 let base = cfg
556 .base_url
557 .clone()
558 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
559 let url = format!("{}/chat/completions", base.trim_end_matches('/'));
560 let key = resolve_api_key(cfg)?;
561
562 let body = build_chat_body(&req, false)?;
563
564 let mut builder = client.post(url).json(&body);
565 if let Some(key) = key {
566 builder = builder.bearer_auth(key);
567 }
568 for (k, v) in &cfg.extra_headers {
569 builder = builder.header(k, v);
570 }
571
572 let (parsed, headers) = send_json::<OpenAIChatResponse>(builder).await?;
573 let first_message = parsed.choices.first().map(|c| &c.message);
574 let content = first_message
575 .and_then(|message| extract_text_value(message.content.as_ref()))
576 .unwrap_or_default();
577 let reasoning = first_message.and_then(extract_reasoning);
578 let tool_calls = first_message
579 .and_then(|m| m.tool_calls.clone())
580 .filter(|v| !matches!(v, Value::Null) && !matches!(v, Value::Array(a) if a.is_empty()));
581 let header_cost = headers
582 .get("x-litellm-response-cost")
583 .and_then(|v| v.to_str().ok())
584 .and_then(|v| v.parse::<f64>().ok());
585 let mut usage = map_usage(parsed.usage);
586 if usage.cost_usd.is_none() {
587 usage.cost_usd = header_cost;
588 }
589
590 Ok(ChatResponse {
591 content,
592 reasoning,
593 tool_calls,
594 usage,
595 response_id: parsed.id,
596 header_cost,
597 raw: None,
598 })
599}
600
601fn extract_reasoning(message: &OpenAIMessage) -> Option<Reasoning> {
602 message
603 .reasoning_details
604 .as_ref()
605 .and_then(|details| details.as_array().cloned())
606 .and_then(Reasoning::from_details)
607 .or_else(|| {
608 message
609 .reasoning_content
610 .as_ref()
611 .or(message.reasoning.as_ref())
612 .cloned()
613 .and_then(Reasoning::from_text)
614 })
615}
616
617fn extract_text_value(value: Option<&Value>) -> Option<String> {
618 let mut out = String::new();
619 collect_text_fragments(value?, &mut out);
620 (!out.trim().is_empty()).then_some(out)
621}
622
623fn collect_text_fragments(value: &Value, out: &mut String) {
624 match value {
625 Value::String(text) => out.push_str(text),
626 Value::Array(items) => {
627 for item in items {
628 collect_text_fragments(item, out);
629 }
630 }
631 Value::Object(map) => {
632 if let Some(text) = map.get("text") {
633 collect_text_fragments(text, out);
634 } else if let Some(content) = map.get("content") {
635 collect_text_fragments(content, out);
636 } else if let Some(value) = map.get("value") {
637 collect_text_fragments(value, out);
638 }
639 }
640 _ => {}
641 }
642}
643
644pub async fn chat_stream(
645 client: &Client,
646 cfg: &ProviderConfig,
647 req: ChatRequest,
648) -> Result<ChatStream> {
649 let base = cfg
650 .base_url
651 .clone()
652 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
653 let url = format!("{}/chat/completions", base.trim_end_matches('/'));
654 let key = resolve_api_key(cfg)?;
655
656 let body = build_chat_body(&req, true)?;
657
658 let mut builder = client.post(url).json(&body);
659 if let Some(key) = key {
660 builder = builder.bearer_auth(key);
661 }
662 for (k, v) in &cfg.extra_headers {
663 builder = builder.header(k, v);
664 }
665
666 let resp = builder.send().await.map_err(LiteLLMError::from)?;
667 let status = resp.status();
668 if !status.is_success() {
669 let text = resp.text().await.map_err(LiteLLMError::from)?;
670 return Err(LiteLLMError::http(format!(
671 "http {}: {}",
672 status.as_u16(),
673 text
674 )));
675 }
676
677 Ok(parse_sse_stream(resp.bytes_stream()))
678}
679
680pub async fn embeddings(
681 client: &Client,
682 cfg: &ProviderConfig,
683 req: EmbeddingRequest,
684) -> Result<EmbeddingResponse> {
685 let base = cfg
686 .base_url
687 .clone()
688 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
689 let url = format!("{}/embeddings", base.trim_end_matches('/'));
690 let key = resolve_api_key(cfg)?;
691
692 let mut body = serde_json::json!({
693 "model": req.model,
694 "input": req.input,
695 });
696 merge_extra_body(&mut body, req.extra_body.as_ref(), &["model", "input"])?;
697
698 let mut builder = client.post(url).json(&body);
699 if let Some(key) = key {
700 builder = builder.bearer_auth(key);
701 }
702 for (k, v) in &cfg.extra_headers {
703 builder = builder.header(k, v);
704 }
705
706 let (parsed, _headers) = send_json::<OpenAIEmbeddingResponse>(builder).await?;
707 let vectors = parsed.data.into_iter().map(|d| d.embedding).collect();
708
709 Ok(EmbeddingResponse {
710 vectors,
711 usage: map_usage(parsed.usage),
712 raw: None,
713 })
714}
715
716pub async fn image_generation(
717 client: &Client,
718 cfg: &ProviderConfig,
719 req: ImageRequest,
720) -> Result<ImageResponse> {
721 let base = cfg
722 .base_url
723 .clone()
724 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
725 let url = format!("{}/images/generations", base.trim_end_matches('/'));
726 let key = resolve_api_key(cfg)?;
727
728 let mut body = serde_json::json!({
729 "model": req.model,
730 "prompt": req.prompt,
731 });
732 if let Some(n) = req.n {
733 body["n"] = serde_json::json!(n);
734 }
735 if let Some(ref size) = req.size {
736 body["size"] = serde_json::json!(size);
737 }
738 if let Some(ref quality) = req.quality {
739 body["quality"] = serde_json::json!(quality);
740 }
741 if let Some(ref background) = req.background {
742 body["background"] = serde_json::json!(background);
743 }
744 merge_extra_body(&mut body, req.extra_body.as_ref(), &["model", "prompt"])?;
745
746 let mut builder = client.post(url).json(&body);
747 if let Some(key) = key {
748 builder = builder.bearer_auth(key);
749 }
750 for (k, v) in &cfg.extra_headers {
751 builder = builder.header(k, v);
752 }
753
754 let (parsed, _headers) = send_json::<Value>(builder).await?;
755 let images = parsed
756 .get("data")
757 .and_then(|v| v.as_array())
758 .map(|arr| {
759 arr.iter()
760 .map(|item| ImageData {
761 b64_json: item
762 .get("b64_json")
763 .and_then(|v| v.as_str())
764 .map(|s| s.to_string()),
765 url: item
766 .get("url")
767 .and_then(|v| v.as_str())
768 .map(|s| s.to_string()),
769 revised_prompt: item
770 .get("revised_prompt")
771 .and_then(|v| v.as_str())
772 .map(|s| s.to_string()),
773 mime_type: None,
774 })
775 .collect::<Vec<_>>()
776 })
777 .unwrap_or_default();
778
779 Ok(ImageResponse {
780 images,
781 usage: Usage::default(),
782 raw: None,
783 })
784}
785
786#[derive(Debug, Clone)]
788pub struct VideoGenerationOptions {
789 pub max_poll_attempts: u32,
791 pub poll_interval_secs: u64,
793}
794
795impl Default for VideoGenerationOptions {
796 fn default() -> Self {
797 Self {
798 max_poll_attempts: DEFAULT_VIDEO_MAX_POLL_ATTEMPTS,
799 poll_interval_secs: DEFAULT_VIDEO_POLL_INTERVAL_SECS,
800 }
801 }
802}
803
804pub async fn video_generation(
805 client: &Client,
806 cfg: &ProviderConfig,
807 req: VideoRequest,
808) -> Result<VideoResponse> {
809 video_generation_with_options(client, cfg, req, VideoGenerationOptions::default()).await
810}
811
812pub async fn video_generation_with_options(
813 client: &Client,
814 cfg: &ProviderConfig,
815 req: VideoRequest,
816 options: VideoGenerationOptions,
817) -> Result<VideoResponse> {
818 let base = cfg
819 .base_url
820 .clone()
821 .ok_or_else(|| LiteLLMError::Config("base_url required".into()))?;
822 let url = format!("{}/videos", base.trim_end_matches('/'));
823 let key = resolve_api_key(cfg)?;
824
825 let mut form = Form::new()
826 .text("model", req.model)
827 .text("prompt", req.prompt);
828 if let Some(seconds) = req.seconds {
829 form = form.text("seconds", seconds.to_string());
830 }
831 if let Some(size) = req.size {
832 form = form.text("size", size);
833 }
834
835 let mut builder = client.post(url).multipart(form);
836 if let Some(ref key) = key {
837 builder = builder.bearer_auth(key.clone());
838 }
839 for (k, v) in &cfg.extra_headers {
840 builder = builder.header(k, v);
841 }
842
843 let (parsed, _headers) = send_json::<Value>(builder).await?;
844 let video_id = parsed
845 .get("id")
846 .and_then(|v| v.as_str())
847 .ok_or_else(|| LiteLLMError::Parse("missing video id".into()))?;
848
849 let status_url = format!("{}/videos/{}", base.trim_end_matches('/'), video_id);
850 let poll_interval = Duration::from_secs(options.poll_interval_secs);
851
852 for attempt in 0..options.max_poll_attempts {
853 let mut status_builder = client.get(&status_url);
854 if let Some(ref key) = key {
855 status_builder = status_builder.bearer_auth(key.clone());
856 }
857 let (status_resp, _headers) = send_json::<Value>(status_builder).await?;
858 let status = status_resp
859 .get("status")
860 .and_then(|v| v.as_str())
861 .unwrap_or("unknown");
862
863 match status {
864 "completed" => {
865 return fetch_video_content(client, &base, video_id, key.as_deref()).await;
866 }
867 "failed" => {
868 let msg = status_resp
869 .get("error")
870 .and_then(|v| v.as_str())
871 .unwrap_or("video generation failed");
872 return Err(LiteLLMError::http(msg.to_string()));
873 }
874 _ => {
875 if attempt + 1 >= options.max_poll_attempts {
876 return Err(LiteLLMError::http(format!(
877 "video generation timed out after {} attempts",
878 options.max_poll_attempts
879 )));
880 }
881 sleep(poll_interval).await;
882 }
883 }
884 }
885
886 Err(LiteLLMError::http("video generation timed out"))
887}
888
889async fn fetch_video_content(
890 client: &Client,
891 base: &str,
892 video_id: &str,
893 key: Option<&str>,
894) -> Result<VideoResponse> {
895 let content_url = format!("{}/videos/{}/content", base.trim_end_matches('/'), video_id);
896 let mut content_builder = client.get(&content_url);
897 if let Some(key) = key {
898 content_builder = content_builder.bearer_auth(key);
899 }
900
901 let bytes = content_builder
902 .send()
903 .await
904 .map_err(LiteLLMError::from)?
905 .bytes()
906 .await
907 .map_err(LiteLLMError::from)?;
908 let b64 = general_purpose::STANDARD.encode(bytes);
909
910 Ok(VideoResponse {
911 video_url: Some(format!("data:video/mp4;base64,{b64}")),
912 raw: None,
913 })
914}
915
916fn map_usage(usage: Option<OpenAIUsage>) -> Usage {
917 usage.map_or_else(Usage::default, |u| Usage {
918 prompt_tokens: u.prompt_tokens,
919 completion_tokens: u.completion_tokens,
920 thoughts_tokens: u.completion_tokens_details.and_then(|d| d.reasoning_tokens),
921 total_tokens: u.total_tokens,
922 cost_usd: parse_cost(u.cost.as_ref()),
923 })
924}
925
926fn parse_cost(value: Option<&Value>) -> Option<f64> {
927 let v = value?;
928 if let Some(n) = v.as_f64() {
929 return Some(n);
930 }
931 if let Some(s) = v.as_str() {
932 return s.parse::<f64>().ok();
933 }
934 None
935}