feat(xai): add native image and video endpoints

Expose the xAI Imagine image and video surfaces on top of the `xai`
provider, and make the shared OpenAI video-task layer survive the
production configuration they need.

Native video requests live under /v1 (generations, edits, extensions,
with /v1/videos as a creation alias that only selects xAI candidates);
the OpenAI-compatible adapter stays under /openai/v1/videos and maps
`seconds` / `size` onto numeric duration, aspect ratio and resolution.
Clients receive an opaque Aether task ID scoped to the owning user;
polling uses the upstream task ID and the original credential, and
completed downloads fetch the returned media URL without forwarding
provider authorization to the media host.

Three fixes to the shared video layer are required for this to work
outside tests:

- OpenAI/xAI task persistence now supplies a stable 16-character
  short_id, which the PostgreSQL schema requires. Existing rows keep
  their original value across reconstruction, so no schema change or
  historical rewrite is needed.
- Task retrieval and content downloads are admitted by the production
  GET execution gate, and reconstructed tasks resolve proxy nodes,
  system proxy defaults, tunnel affinity and transport profiles through
  the same deployment resolver used for creation. A configured proxy
  route no longer silently becomes a direct request after restart.
- When the gateway also serves the frontend, /openai/v1/videos and its
  subpaths bypass the static SPA handler. Otherwise a video query
  returns HTTP 200 with text/html instead of the task JSON.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
stabey
2026-09-14 21:17:21 +08:00
co-authored by Claude Opus 5
parent e83399db2f
commit 04c4a97766
42 changed files with 1880 additions and 164 deletions
@@ -776,18 +776,26 @@ mod tests {
&transport,
RequestConversionKind::ToOpenAiResponses
));
assert!(
!request_pair_allowed_for_transport(
&transport,
"openai:responses:compact",
"openai:responses"
),
"compact must not convert onto xAI Responses"
);
assert!(!request_pair_allowed_for_transport(
&transport,
"openai:image",
"openai:responses"
));
assert!(!request_pair_allowed_for_transport(
&transport,
"openai:video",
"openai:responses"
));
for isolated in ["openai:responses:compact", "openai:image", "openai:video"] {
assert!(
!request_pair_allowed_for_transport(&transport, isolated, "openai:responses"),
"{isolated} must not convert onto xAI Responses"
);
}
}
#[test]
fn xai_compact_endpoint_is_same_format_only() {
fn xai_compact_and_media_endpoints_are_same_format_only() {
let compact = transport_snapshot("xai", "openai:responses:compact", "oauth", true, None);
assert!(request_pair_allowed_for_transport(
&compact,
@@ -809,6 +817,25 @@ mod tests {
"{client_api_format} must not convert onto xAI compact"
);
}
for api_format in ["openai:image", "openai:video"] {
let transport = transport_snapshot("xai", api_format, "oauth", true, None);
assert!(
request_pair_allowed_for_transport(&transport, api_format, api_format),
"{api_format} same-format transport should be allowed"
);
for client_api_format in [
"openai:chat",
"openai:responses",
"claude:messages",
"gemini:generate_content",
] {
assert!(
!request_pair_allowed_for_transport(&transport, client_api_format, api_format),
"{client_api_format} must not convert onto {api_format}"
);
}
}
}
#[test]
@@ -84,6 +84,11 @@ fn is_dedicated_openai_image_provider(transport: &GatewayProviderTransportSnapsh
.trim()
.eq_ignore_ascii_case("codex")
|| is_grok_provider_transport(transport)
|| transport
.provider
.provider_type
.trim()
.eq_ignore_ascii_case("xai")
}
pub fn resolve_openai_image_auth(
@@ -92,7 +97,10 @@ pub fn resolve_openai_image_auth(
if is_grok_provider_transport(transport) {
return resolve_grok_session_auth(transport);
}
resolve_local_openai_bearer_auth(transport)
resolve_local_openai_bearer_auth(transport).or_else(|| {
crate::generic_oauth::resolve_local_generic_oauth_transport_authorization(transport)
.map(|value| ("authorization".to_string(), value))
})
}
pub fn build_openai_image_upstream_url(
@@ -100,7 +108,11 @@ pub fn build_openai_image_upstream_url(
request_path: Option<&str>,
request_query: Option<&str>,
) -> String {
build_openai_image_url(&transport.endpoint.base_url, request_path, request_query)
build_openai_image_url(
&crate::xai::resolved_xai_request_base_url(transport, "openai:image"),
request_path,
request_query,
)
}
pub fn build_openai_image_headers(
@@ -113,6 +125,11 @@ pub fn build_openai_image_headers(
&BTreeMap::new(),
);
provider_request_headers.insert("content-type".to_string(), "application/json".to_string());
crate::xai::insert_cli_identity_headers_if_needed(
input.transport,
"openai:image",
&mut provider_request_headers,
);
if let Some(accept) = input.accept {
provider_request_headers.insert("accept".to_string(), accept.to_string());
} else {
@@ -280,6 +297,48 @@ mod tests {
);
}
#[test]
fn xai_oauth_image_uses_cli_proxy() {
let mut transport = sample_transport();
transport.provider.provider_type = "xai".to_string();
transport.endpoint.base_url = "https://cli-chat-proxy.grok.com/v1".to_string();
transport.key.auth_type = "oauth".to_string();
transport.key.decrypted_auth_config =
Some(r#"{"refresh_token":"rt","using_api":false}"#.to_string());
assert_eq!(
openai_image_transport_unsupported_reason(&transport, "openai:image"),
None
);
assert_eq!(
build_openai_image_upstream_url(&transport, Some("/v1/images/generations"), None),
"https://cli-chat-proxy.grok.com/v1/images/generations"
);
assert_eq!(
build_openai_image_upstream_url(&transport, Some("/v1/images/edits"), None),
"https://cli-chat-proxy.grok.com/v1/images/edits"
);
let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput {
transport: &transport,
headers: &HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer test-token",
accept: None,
header_rules: None,
provider_request_body: &json!({"prompt": "A cat"}),
original_request_body: &json!({"prompt": "A cat"}),
})
.unwrap();
assert_eq!(
headers.get("x-xai-token-auth").map(String::as_str),
Some("xai-grok-cli")
);
assert_eq!(
headers.get("authorization").map(String::as_str),
Some("Bearer test-token")
);
}
#[test]
fn codex_is_supported_by_dedicated_openai_image_transport_policy() {
let mut transport = sample_transport();
@@ -474,6 +474,18 @@ const XAI_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate
custom_path: None,
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
},
FixedProviderEndpointTemplate {
item_key: "openai:image",
api_format: "openai:image",
custom_path: None,
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
},
FixedProviderEndpointTemplate {
item_key: "openai:video",
api_format: "openai:video",
custom_path: None,
config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS,
},
],
runtime_policy: XAI_RUNTIME_POLICY,
};
@@ -869,7 +881,7 @@ mod tests {
}
#[test]
fn xai_fixed_provider_template_exposes_responses_endpoints() {
fn xai_fixed_provider_template_exposes_responses_media_endpoints() {
let template = fixed_provider_template("xai").expect("xai template should exist");
assert_eq!(template.provider_type, "xai");
assert_eq!(template.base_url, crate::xai::XAI_CHAT_PROXY_BASE_URL);
@@ -880,7 +892,12 @@ mod tests {
.iter()
.map(|item| item.api_format)
.collect::<Vec<_>>(),
vec!["openai:responses", "openai:responses:compact"]
vec![
"openai:responses",
"openai:responses:compact",
"openai:image",
"openai:video"
]
);
let policy = provider_runtime_policy("xai");
+225 -21
View File
@@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use std::fmt;
use aether_contracts::ProxySnapshot;
use aether_data_contracts::repository::video_tasks::StoredVideoTask;
use aether_video_tasks_core::{
LocalVideoTaskSnapshot, LocalVideoTaskTransport, LocalVideoTaskTransportBridgeInput,
@@ -12,11 +13,13 @@ use super::auth::{
build_passthrough_headers_with_auth, resolve_local_gemini_auth,
resolve_local_openai_bearer_auth,
};
use super::network::{resolve_transport_execution_timeouts, resolve_transport_profile};
use super::network::{
resolve_transport_execution_timeouts, resolve_transport_profile,
resolve_transport_proxy_snapshot,
};
use super::policy::{
local_gemini_transport_unsupported_reason_with_network,
local_standard_transport_unsupported_reason_with_network, supports_local_gemini_transport,
supports_local_standard_transport,
local_standard_transport_unsupported_reason_with_network,
};
use super::rules::{
apply_local_body_rules_with_request_headers, apply_local_header_rules_with_request_headers,
@@ -32,6 +35,7 @@ pub enum ProviderVideoCreateFamily {
#[derive(Clone, Copy)]
pub struct ProviderVideoCreateHeadersInput<'a> {
pub transport: &'a GatewayProviderTransportSnapshot,
pub headers: &'a http::HeaderMap,
pub auth_header: &'a str,
pub auth_value: &'a str,
@@ -79,6 +83,13 @@ pub trait VideoTaskTransportSnapshotLookup: Send + Sync {
endpoint_id: &str,
key_id: &str,
) -> Result<Option<GatewayProviderTransportSnapshot>, String>;
async fn resolve_video_task_proxy(
&self,
transport: &GatewayProviderTransportSnapshot,
) -> Option<ProxySnapshot> {
resolve_transport_proxy_snapshot(transport)
}
}
pub fn resolve_local_video_task_transport(
@@ -89,13 +100,17 @@ pub fn resolve_local_video_task_transport(
let api_format = api_format.trim();
let (auth_header, auth_value) = match api_format {
"openai:video" => {
if !supports_local_standard_transport(transport, api_format) {
if local_standard_transport_unsupported_reason_with_network(transport, api_format)
.is_some()
{
return None;
}
resolve_local_openai_bearer_auth(transport)?
resolve_openai_compatible_video_auth(transport)?
}
"gemini:video" => {
if !supports_local_gemini_transport(transport, api_format) {
if local_gemini_transport_unsupported_reason_with_network(transport, api_format)
.is_some()
{
return None;
}
resolve_local_gemini_auth(transport)?
@@ -103,9 +118,9 @@ pub fn resolve_local_video_task_transport(
_ => return None,
};
Some(LocalVideoTaskTransport::from_bridge_input(
LocalVideoTaskTransportBridgeInput {
upstream_base_url: transport.endpoint.base_url.clone(),
let mut resolved =
LocalVideoTaskTransport::from_bridge_input(LocalVideoTaskTransportBridgeInput {
upstream_base_url: crate::xai::resolved_xai_request_base_url(transport, api_format),
provider_name: Some(transport.provider.name.clone()),
provider_id: transport.provider.id.clone(),
endpoint_id: transport.endpoint.id.clone(),
@@ -114,11 +129,12 @@ pub fn resolve_local_video_task_transport(
auth_value,
content_type: Some("application/json".to_string()),
model_name,
proxy: None,
proxy: resolve_transport_proxy_snapshot(transport),
transport_profile: resolve_transport_profile(transport),
timeouts: resolve_transport_execution_timeouts(transport),
},
))
});
crate::xai::insert_cli_identity_headers_if_needed(transport, api_format, &mut resolved.headers);
Some(resolved)
}
pub fn video_create_transport_unsupported_reason(
@@ -141,7 +157,7 @@ pub fn resolve_video_create_auth(
family: ProviderVideoCreateFamily,
) -> Option<(String, String)> {
match family {
ProviderVideoCreateFamily::OpenAi => resolve_local_openai_bearer_auth(transport),
ProviderVideoCreateFamily::OpenAi => resolve_openai_compatible_video_auth(transport),
ProviderVideoCreateFamily::Gemini => resolve_local_gemini_auth(transport),
}
}
@@ -173,6 +189,15 @@ pub fn build_video_create_request_body(
Some(provider_request_body)
}
fn resolve_openai_compatible_video_auth(
transport: &GatewayProviderTransportSnapshot,
) -> Option<(String, String)> {
resolve_local_openai_bearer_auth(transport).or_else(|| {
crate::generic_oauth::resolve_local_generic_oauth_transport_authorization(transport)
.map(|value| ("authorization".to_string(), value))
})
}
pub fn build_video_create_upstream_url(
transport: &GatewayProviderTransportSnapshot,
request_path: &str,
@@ -193,7 +218,13 @@ pub fn build_video_create_upstream_url(
ProviderVideoCreateFamily::Gemini => &["key"][..],
};
return build_passthrough_path_url(
&transport.endpoint.base_url,
&crate::xai::resolved_xai_request_base_url(
transport,
match family {
ProviderVideoCreateFamily::OpenAi => "openai:video",
ProviderVideoCreateFamily::Gemini => "gemini:video",
},
),
path,
request_query,
blocked_keys,
@@ -202,8 +233,14 @@ pub fn build_video_create_upstream_url(
match family {
ProviderVideoCreateFamily::OpenAi => build_passthrough_path_url(
&transport.endpoint.base_url,
openai_video_api_root_request_path(request_path),
&crate::xai::resolved_xai_request_base_url(transport, "openai:video"),
if crate::xai::is_xai_provider_transport(transport)
&& matches!(request_path, "/v1/videos" | "/openai/v1/videos")
{
"/videos/generations"
} else {
openai_video_api_root_request_path(request_path)
},
request_query,
&[],
),
@@ -216,6 +253,7 @@ pub fn build_video_create_upstream_url(
}
fn openai_video_api_root_request_path(request_path: &str) -> &str {
let request_path = request_path.strip_prefix("/openai").unwrap_or(request_path);
if request_path.starts_with("/v1/") {
&request_path[3..]
} else {
@@ -232,6 +270,11 @@ pub fn build_video_create_headers(
input.auth_value,
&BTreeMap::new(),
);
crate::xai::insert_cli_identity_headers_if_needed(
input.transport,
"openai:video",
&mut provider_request_headers,
);
if !apply_local_header_rules_with_request_headers(
&mut provider_request_headers,
input.header_rules,
@@ -281,16 +324,22 @@ pub async fn reconstruct_local_video_task_snapshot(
return Ok(None);
};
let Some(local_transport) =
let Some(mut local_transport) =
resolve_local_video_task_transport(&transport, provider_api_format, task.model.clone())
else {
return Ok(None);
};
Ok(LocalVideoTaskSnapshot::from_stored_task_with_transport(
task,
local_transport,
))
// Resolve deployment-managed nodes, system defaults and tunnel affinity just as
// creation does; serialized task metadata intentionally contains no credentials.
local_transport.proxy = lookup.resolve_video_task_proxy(&transport).await;
let mut snapshot =
LocalVideoTaskSnapshot::from_stored_task_with_transport(task, local_transport);
if let Some(LocalVideoTaskSnapshot::OpenAi(seed)) = &mut snapshot {
seed.xai_provider = crate::xai::is_xai_provider_transport(&transport);
}
Ok(snapshot)
}
#[cfg(test)]
@@ -441,6 +490,46 @@ mod tests {
assert_eq!(transport.provider_id, "provider-1");
}
#[tokio::test]
async fn reconstructs_video_with_configured_proxy_and_profile() {
let mut transport = sample_transport("openai:video", "oauth");
transport.provider.provider_type = "xai".into();
transport.endpoint.base_url = "https://cli-chat-proxy.grok.com/v1".into();
transport.provider.proxy = Some(json!({"enabled":true,"url":"http://127.0.0.1:9876"}));
transport.provider.config = Some(json!({"fingerprint":{"transport_profile":{
"profile_id":"test-video","backend":"reqwest_rustls","http_mode":"auto","pool_scope":"key"
}}}));
transport.key.decrypted_auth_config = Some(r#"{"using_api":false}"#.into());
let lookup = TestLookup(Some(transport));
let snapshot = reconstruct_local_video_task_snapshot(&lookup, &sample_stored_video_task())
.await
.unwrap()
.expect("proxied video must resume after restart");
let LocalVideoTaskSnapshot::OpenAi(seed) = snapshot else {
panic!("expected OpenAI video")
};
assert!(seed.xai_provider);
assert_eq!(
seed.transport.proxy.as_ref().unwrap().url.as_deref(),
Some("http://127.0.0.1:9876/")
);
assert_eq!(
seed.transport
.transport_profile
.as_ref()
.unwrap()
.profile_id,
"test-video"
);
assert_eq!(
seed.transport
.headers
.get("x-xai-token-auth")
.map(String::as_str),
Some("xai-grok-cli")
);
}
#[test]
fn resolves_gemini_video_transport() {
let transport = resolve_local_video_task_transport(
@@ -489,6 +578,120 @@ mod tests {
assert_eq!(url, "https://api.openai.example/v1/videos?trace=1");
}
#[test]
fn xai_video_create_paths_preserve_auth_hosts_and_custom_endpoints() {
for (auth, base) in [
("oauth", "https://cli-chat-proxy.grok.com/v1"),
("api_key", "https://api.x.ai/v1"),
] {
let mut transport = sample_transport("openai:video", auth);
transport.provider.provider_type = "xai".into();
transport.endpoint.base_url = "https://cli-chat-proxy.grok.com/v1".into();
transport.key.decrypted_auth_config =
(auth == "oauth").then(|| r#"{"using_api":false}"#.into());
for path in ["/v1/videos", "/openai/v1/videos", "/v1/videos/generations"] {
assert_eq!(
build_video_create_upstream_url(
&transport,
path,
Some("trace=1"),
"grok-imagine-video",
ProviderVideoCreateFamily::OpenAi
)
.unwrap(),
format!("{base}/videos/generations?trace=1")
);
}
transport.endpoint.base_url = "https://gateway.example/prefix/v1".into();
assert_eq!(
build_video_create_upstream_url(
&transport,
"/openai/v1/videos",
None,
"grok-imagine-video",
ProviderVideoCreateFamily::OpenAi
)
.unwrap(),
"https://gateway.example/prefix/v1/videos/generations"
);
transport.endpoint.custom_path = Some("/custom/videos/generations".into());
let url = build_video_create_upstream_url(
&transport,
"/openai/v1/videos",
None,
"grok-imagine-video",
ProviderVideoCreateFamily::OpenAi,
)
.unwrap();
assert!(url.ends_with("/custom/videos/generations"), "{url}");
}
let transport = sample_transport("openai:video", "api_key");
assert_eq!(
build_video_create_upstream_url(
&transport,
"/openai/v1/videos",
None,
"sora",
ProviderVideoCreateFamily::OpenAi
),
build_video_create_upstream_url(
&transport,
"/v1/videos",
None,
"sora",
ProviderVideoCreateFamily::OpenAi
)
);
}
#[test]
fn xai_oauth_video_uses_cli_proxy() {
let mut transport = sample_transport("openai:video", "oauth");
transport.provider.provider_type = "xai".to_string();
transport.endpoint.base_url = "https://cli-chat-proxy.grok.com/v1".to_string();
transport.key.decrypted_auth_config =
Some(r#"{"refresh_token":"rt","using_api":false}"#.to_string());
let url = build_video_create_upstream_url(
&transport,
"/v1/videos/generations",
None,
"grok-imagine-video",
ProviderVideoCreateFamily::OpenAi,
)
.expect("url should build");
assert_eq!(url, "https://cli-chat-proxy.grok.com/v1/videos/generations");
let headers = build_video_create_headers(ProviderVideoCreateHeadersInput {
transport: &transport,
headers: &http::HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer test-token",
header_rules: None,
provider_request_body: &json!({"prompt": "A cat"}),
original_request_body: &json!({"prompt": "A cat"}),
})
.unwrap();
assert_eq!(
headers.get("x-xai-token-auth").map(String::as_str),
Some("xai-grok-cli")
);
let reconstructed = super::resolve_local_video_task_transport(
&transport,
"openai:video",
Some("grok-imagine-video".into()),
)
.unwrap();
assert_eq!(
reconstructed.upstream_base_url,
"https://cli-chat-proxy.grok.com/v1"
);
assert_eq!(
reconstructed.headers.get("x-xai-token-auth"),
headers.get("x-xai-token-auth")
);
}
#[test]
fn builds_gemini_video_create_url_and_removes_client_key_query() {
let transport = sample_transport("gemini:video", "api_key");
@@ -512,6 +715,7 @@ mod tests {
let provider_request_body = json!({"prompt": "make a clip"});
let original_request_body = provider_request_body.clone();
let headers = build_video_create_headers(ProviderVideoCreateHeadersInput {
transport: &sample_transport("openai:video", "bearer"),
headers: &http::HeaderMap::new(),
auth_header: "authorization",
auth_value: "Bearer secret",
@@ -1,3 +1,5 @@
pub mod video;
use std::collections::BTreeMap;
use aether_ai_formats::normalize_api_format_alias;
@@ -343,6 +345,39 @@ mod tests {
));
}
#[test]
fn media_routing_and_cli_headers_follow_auth_and_base_url() {
for api_format in ["openai:image", "openai:video"] {
for stored in ["", XAI_API_BASE_URL, XAI_CHAT_PROXY_BASE_URL] {
for (auth_type, config, expected) in [
(
"oauth",
Some(r#"{"refresh_token":"rt","using_api":false}"#),
XAI_CHAT_PROXY_BASE_URL,
),
("oauth", Some(r#"{"using_api":true}"#), XAI_API_BASE_URL),
("bearer", None, XAI_API_BASE_URL),
] {
let transport = sample_transport(auth_type, config, stored);
assert_eq!(
resolved_xai_upstream_base_url(&transport, api_format).as_deref(),
Some(expected)
);
assert_eq!(
should_attach_cli_identity_headers(&transport, api_format),
expected == XAI_CHAT_PROXY_BASE_URL
);
}
}
let custom = sample_transport("oauth", None, "https://custom.example/v1");
assert_eq!(
resolved_xai_upstream_base_url(&custom, api_format).as_deref(),
Some("https://custom.example/v1")
);
assert!(!should_attach_cli_identity_headers(&custom, api_format));
}
}
#[test]
fn bearer_without_refresh_uses_official_api() {
let transport = sample_transport("bearer", None, XAI_CHAT_PROXY_BASE_URL);
@@ -0,0 +1,147 @@
use serde_json::{json, Value};
/// Native xAI video requests live under /v1; the OpenAI-compatible adapter under /openai/v1.
pub fn is_native_video_request(provider_type: &str, path: &str) -> bool {
provider_type.trim().eq_ignore_ascii_case("xai")
&& matches!(
path,
"/v1/videos" | "/v1/videos/generations" | "/v1/videos/edits" | "/v1/videos/extensions"
)
}
pub fn is_explicit_native_video_path(path: &str) -> bool {
matches!(
path,
"/v1/videos/generations" | "/v1/videos/edits" | "/v1/videos/extensions"
)
}
/// Convert the OpenAI video request contract to xAI's native contract.
/// Native requests bypass this adapter so provider-specific fields remain intact.
pub fn convert_openai_video_request(body: &Value) -> Result<Value, &'static str> {
let prompt = text(&body["prompt"]).ok_or("prompt is required")?;
let seconds = match &body["seconds"] {
Value::Null => 4,
Value::String(value) if value.trim().is_empty() => 4,
Value::String(value) => value
.trim()
.parse::<i64>()
.map_err(|_| "seconds must be an integer")?,
value => value.as_i64().ok_or("seconds must be an integer")?,
}
.clamp(1, 15);
let size = text(&body["size"]).unwrap_or("720x1280");
let default_ratio = match size {
"720x1280" | "1024x1792" => "9:16",
"1280x720" | "1792x1024" => "16:9",
_ => return Err("size must be one of 720x1280, 1280x720, 1024x1792, or 1792x1024"),
};
let ratio = match text(&body["aspect_ratio"])
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"square" | "1:1" => "1:1",
"landscape" | "16:9" => "16:9",
"portrait" | "9:16" => "9:16",
"4:3" => "4:3",
"3:4" => "3:4",
"3:2" => "3:2",
"2:3" => "2:3",
_ => default_ratio,
};
let resolution = if text(&body["resolution"]).is_some_and(|v| v.eq_ignore_ascii_case("480p")) {
"480p"
} else {
"720p"
};
if text(&body["input_reference"]["file_id"]).is_some() {
return Err("input_reference.file_id is not supported for xAI video generation; use input_reference.image_url");
}
let image = text(&body["input_reference"]["image_url"])
.or_else(|| image_url(&body["image"]))
.or_else(|| text(&body["image_url"]));
let references: Vec<_> = ["reference_images", "reference_image_urls"]
.into_iter()
.filter_map(|key| body[key].as_array())
.flatten()
.filter_map(image_url)
.map(|url| json!({"url":url}))
.collect();
if references.len() > 7 {
return Err("reference_images supports at most 7 images on xAI");
}
if image.is_some() && !references.is_empty() {
return Err("image and reference_images cannot be combined on xAI");
}
let mut result = json!({"model":body["model"], "prompt":prompt, "duration":seconds, "aspect_ratio":ratio, "resolution":resolution});
if let Some(url) = image {
result["image"] = json!({"url":url});
}
if !references.is_empty() {
result["reference_images"] = json!(references);
}
Ok(result)
}
fn text(value: &Value) -> Option<&str> {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn image_url(value: &Value) -> Option<&str> {
text(value)
.or_else(|| text(&value["url"]))
.or_else(|| text(&value["image_url"]))
.or_else(|| text(&value["image_url"]["url"]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xai_video_compatibility_maps_duration_size_and_references() {
let converted = convert_openai_video_request(&json!({
"model":"grok-imagine-video", "prompt":"A cat", "seconds":"8", "size":"1280x720",
"reference_images":[{"image_url":{"url":"https://example.com/a.png"}}],
"reference_image_urls":["https://example.com/b.png"]
}))
.unwrap();
assert_eq!(
converted,
json!({"model":"grok-imagine-video", "prompt":"A cat", "duration":8,
"aspect_ratio":"16:9", "resolution":"720p", "reference_images":[{"url":"https://example.com/a.png"},{"url":"https://example.com/b.png"}]})
);
let defaults = convert_openai_video_request(&json!({"prompt":"A cat"})).unwrap();
assert_eq!(defaults["duration"], 4);
assert_eq!(defaults["aspect_ratio"], "9:16");
for (seconds, expected) in [(-1, 1), (30, 15)] {
assert_eq!(
convert_openai_video_request(&json!({"prompt":"A cat", "seconds":seconds}))
.unwrap()["duration"],
expected
);
}
}
#[test]
fn xai_video_compatibility_validates_requests_and_maps_image_input() {
for invalid in [
json!({}),
json!({"prompt":"cat","seconds":"1.5"}),
json!({"prompt":"cat","size":"foo"}),
json!({"prompt":"cat","input_reference":{"file_id":"file-1"}}),
json!({"prompt":"cat","image":"https://example.com/a.png","reference_images":["https://example.com/b.png"]}),
json!({"prompt":"cat","reference_images":vec!["https://example.com/a.png";8]}),
] {
assert!(convert_openai_video_request(&invalid).is_err(), "{invalid}");
}
let body = convert_openai_video_request(&json!({"prompt":"cat","input_reference":{"image_url":"https://example.com/a.png"},"aspect_ratio":"square","resolution":"480p"})).unwrap();
assert_eq!(body["image"]["url"], "https://example.com/a.png");
assert_eq!(body["aspect_ratio"], "1:1");
assert_eq!(body["resolution"], "480p");
}
}