mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-16 16:07:45 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -748,6 +748,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -17,8 +17,8 @@ use crate::ai_serving::transport::{
|
||||
ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GROK_CHAT_PATH,
|
||||
};
|
||||
use crate::ai_serving::{
|
||||
apply_codex_openai_special_headers, build_chatgpt_web_image_request_body,
|
||||
build_codex_openai_image_api_provider_request_body,
|
||||
apply_codex_openai_special_headers, apply_xai_upstream_payload_edits,
|
||||
build_chatgpt_web_image_request_body, build_codex_openai_image_api_provider_request_body,
|
||||
build_gemini_image_request_body_from_openai_image_request,
|
||||
build_openai_image_api_provider_request_body, build_openai_image_provider_request_body,
|
||||
default_model_for_openai_image_operation, normalize_openai_image_request,
|
||||
@@ -211,7 +211,7 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
|
||||
upstream_is_stream,
|
||||
)
|
||||
};
|
||||
let Some(provider_request_body) = provider_request_body else {
|
||||
let Some(mut provider_request_body) = provider_request_body else {
|
||||
mark_skipped_local_openai_image_candidate_with_failure_diagnostic(
|
||||
state,
|
||||
input,
|
||||
@@ -229,6 +229,11 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts(
|
||||
.await;
|
||||
return None;
|
||||
};
|
||||
apply_xai_upstream_payload_edits(
|
||||
&mut provider_request_body,
|
||||
transport.provider.provider_type.as_str(),
|
||||
provider_api_format,
|
||||
);
|
||||
let Some(mut provider_request_headers) = (if is_grok {
|
||||
build_grok_browser_headers(GrokHeaderInput {
|
||||
transport,
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::ai_serving::planner::{
|
||||
build_ai_execution_decision_response, resolve_transport_request_encoding_policy,
|
||||
AiExecutionDecisionResponseParts,
|
||||
};
|
||||
use crate::ai_serving::transport::xai::video::is_native_video_request;
|
||||
use crate::ai_serving::transport::{
|
||||
resolve_transport_execution_timeouts, resolve_transport_profile,
|
||||
};
|
||||
@@ -33,7 +34,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
|
||||
let Some(resolved) = resolve_local_video_create_candidate_payload_parts(
|
||||
state, parts, body_json, trace_id, input, &attempt, spec,
|
||||
)
|
||||
.await
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -52,9 +53,32 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat
|
||||
.await;
|
||||
let transport_profile = resolve_transport_profile(&transport);
|
||||
let mut extra_fields = serde_json::Map::new();
|
||||
if is_native_video_request(&transport.provider.provider_type, parts.uri.path()) {
|
||||
extra_fields.insert(
|
||||
"video_client_protocol".to_string(),
|
||||
serde_json::json!("xai"),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(proxy_value) = build_request_trace_proxy_value(Some(&transport), proxy.as_ref()) {
|
||||
extra_fields.insert("proxy".to_string(), proxy_value);
|
||||
}
|
||||
if transport.provider.provider_type.eq_ignore_ascii_case("xai") {
|
||||
extra_fields.insert("video_provider_xai".into(), serde_json::json!(true));
|
||||
if let Some(duration) = resolved.provider_request_body.get("duration") {
|
||||
extra_fields.insert("video_duration".into(), duration.clone());
|
||||
}
|
||||
if parts.uri.path() == "/openai/v1/videos" {
|
||||
extra_fields.insert(
|
||||
"video_size".into(),
|
||||
body_json
|
||||
.get("size")
|
||||
.filter(|v| v.as_str().is_some_and(|s| !s.trim().is_empty()))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!("720x1280")),
|
||||
);
|
||||
}
|
||||
}
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
let report_context = build_local_execution_report_context(LocalExecutionReportContextParts {
|
||||
auth_context: &input.auth_context,
|
||||
|
||||
@@ -3,15 +3,23 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::ai_serving::planner::candidate_preparation::resolve_candidate_mapped_model;
|
||||
use crate::ai_serving::planner::candidate_preparation::{
|
||||
prepare_header_authenticated_candidate, resolve_candidate_mapped_model, OauthPreparationContext,
|
||||
};
|
||||
use crate::ai_serving::planner::spec_metadata::local_video_create_spec_metadata;
|
||||
use crate::ai_serving::transport::xai::video::{
|
||||
convert_openai_video_request, is_explicit_native_video_path, is_native_video_request,
|
||||
};
|
||||
use crate::ai_serving::transport::{
|
||||
build_video_create_headers, build_video_create_request_body, build_video_create_upstream_url,
|
||||
resolve_video_create_auth, video_create_transport_unsupported_reason,
|
||||
ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput,
|
||||
};
|
||||
use crate::ai_serving::{CandidateFailureDiagnostic, GatewayProviderTransportSnapshot};
|
||||
use crate::AppState;
|
||||
use crate::ai_serving::{
|
||||
apply_xai_upstream_payload_edits, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot,
|
||||
PlannerAppState,
|
||||
};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
use super::support::{
|
||||
mark_skipped_local_video_candidate, mark_skipped_local_video_candidate_with_failure_diagnostic,
|
||||
@@ -37,11 +45,16 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts(
|
||||
input: &LocalVideoCreateDecisionInput,
|
||||
attempt: &LocalVideoCreateCandidateAttempt,
|
||||
spec: LocalVideoCreateSpec,
|
||||
) -> Option<LocalVideoCreateCandidatePayloadParts> {
|
||||
) -> Result<Option<LocalVideoCreateCandidatePayloadParts>, GatewayError> {
|
||||
let spec_metadata = local_video_create_spec_metadata(spec);
|
||||
let candidate = &attempt.eligible.candidate;
|
||||
let transport = &attempt.eligible.transport;
|
||||
let effective_headers = input.effective_headers(&parts.headers);
|
||||
if is_explicit_native_video_path(parts.uri.path())
|
||||
&& !transport.provider.provider_type.eq_ignore_ascii_case("xai")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let provider_family = provider_video_create_family(spec.family);
|
||||
let transport_unsupported_reason = video_create_transport_unsupported_reason(
|
||||
@@ -60,23 +73,39 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts(
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let auth = resolve_video_create_auth(transport, provider_family);
|
||||
let Some((auth_header, auth_value)) = auth else {
|
||||
mark_skipped_local_video_candidate(
|
||||
state,
|
||||
input,
|
||||
let prepared_candidate = match prepare_header_authenticated_candidate(
|
||||
PlannerAppState::new(state),
|
||||
transport,
|
||||
candidate,
|
||||
resolve_video_create_auth(transport, provider_family),
|
||||
OauthPreparationContext {
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
"transport_auth_unavailable",
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
api_format: spec_metadata.api_format,
|
||||
operation: "video_create_candidate_request",
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(prepared) => prepared,
|
||||
Err(skip_reason) => {
|
||||
mark_skipped_local_video_candidate(
|
||||
state,
|
||||
input,
|
||||
trace_id,
|
||||
candidate,
|
||||
attempt.candidate_index,
|
||||
&attempt.candidate_id,
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let auth_header = prepared_candidate.auth_header;
|
||||
let auth_value = prepared_candidate.auth_value;
|
||||
|
||||
let mapped_model = match resolve_candidate_mapped_model(candidate) {
|
||||
Ok(mapped_model) => mapped_model,
|
||||
@@ -91,7 +120,7 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts(
|
||||
skip_reason,
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,10 +146,10 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts(
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(provider_request_body) = build_video_create_request_body(
|
||||
let Some(mut provider_request_body) = build_video_create_request_body(
|
||||
body_json,
|
||||
provider_family,
|
||||
&mapped_model,
|
||||
@@ -142,11 +171,28 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts(
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
return Ok(None);
|
||||
};
|
||||
if transport.provider.provider_type.eq_ignore_ascii_case("xai")
|
||||
&& !is_native_video_request(&transport.provider.provider_type, parts.uri.path())
|
||||
{
|
||||
provider_request_body =
|
||||
convert_openai_video_request(&provider_request_body).map_err(|message| {
|
||||
GatewayError::Client {
|
||||
status: http::StatusCode::BAD_REQUEST,
|
||||
message: message.to_string(),
|
||||
}
|
||||
})?;
|
||||
}
|
||||
apply_xai_upstream_payload_edits(
|
||||
&mut provider_request_body,
|
||||
transport.provider.provider_type.as_str(),
|
||||
spec_metadata.api_format,
|
||||
);
|
||||
|
||||
let Some(provider_request_headers) =
|
||||
build_video_create_headers(ProviderVideoCreateHeadersInput {
|
||||
transport,
|
||||
headers: effective_headers,
|
||||
auth_header: &auth_header,
|
||||
auth_value: &auth_value,
|
||||
@@ -170,10 +216,10 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts(
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return None;
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Some(LocalVideoCreateCandidatePayloadParts {
|
||||
Ok(Some(LocalVideoCreateCandidatePayloadParts {
|
||||
transport: Arc::clone(transport),
|
||||
auth_header,
|
||||
auth_value,
|
||||
@@ -181,7 +227,7 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts(
|
||||
provider_request_headers,
|
||||
provider_request_body,
|
||||
upstream_url,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn provider_video_create_family(family: LocalVideoCreateFamily) -> ProviderVideoCreateFamily {
|
||||
|
||||
@@ -53,6 +53,8 @@ const AI_ANY_ROUTE_PATTERNS: &[&str] = &[
|
||||
"/v1beta/operations/{*operation_path}",
|
||||
"/v1/videos",
|
||||
"/v1/videos/{*video_path}",
|
||||
"/openai/v1/videos",
|
||||
"/openai/v1/videos/{*video_path}",
|
||||
"/upload/v1beta/files",
|
||||
"/v1beta/files",
|
||||
"/v1beta/files/{*file_path}",
|
||||
|
||||
@@ -536,6 +536,9 @@ mod tests {
|
||||
|
||||
fn sample_sparse_stored_task() -> StoredVideoTask {
|
||||
let snapshot = LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-1".to_string(),
|
||||
upstream_task_id: "ext-1".to_string(),
|
||||
created_at_unix_ms: 1,
|
||||
|
||||
@@ -140,6 +140,8 @@ pub(crate) const RUST_FRONTDOOR_OWNED_ROUTE_PATTERNS: &[&str] = &[
|
||||
"/v1beta/models/{model}/operations/{id}",
|
||||
"/v1beta/operations",
|
||||
"/v1beta/operations/{id}",
|
||||
"/openai/v1/videos",
|
||||
"/openai/v1/videos/{path...}",
|
||||
"/v1/videos",
|
||||
"/v1/videos/{path...}",
|
||||
"/upload/v1beta/files",
|
||||
|
||||
@@ -137,7 +137,11 @@ pub(super) fn classify_ai_public_route(
|
||||
.with_client_surface(detect_claude_client_surface(headers))
|
||||
.with_api_operation(ApiOperation::ClaudeMessagesCreate),
|
||||
)
|
||||
} else if normalized_path.starts_with("/v1/videos") {
|
||||
} else if normalized_path == "/v1/videos"
|
||||
|| normalized_path.starts_with("/v1/videos/")
|
||||
|| normalized_path == "/openai/v1/videos"
|
||||
|| normalized_path.starts_with("/openai/v1/videos/")
|
||||
{
|
||||
Some(classified(
|
||||
"ai_public",
|
||||
"openai",
|
||||
|
||||
@@ -123,6 +123,15 @@ impl GatewayDataState {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn attach_video_task_repository_for_tests<T>(mut self, repository: Arc<T>) -> Self
|
||||
where
|
||||
T: VideoTaskRepository + 'static,
|
||||
{
|
||||
self.video_task_reader = Some(repository.clone());
|
||||
self.video_task_writer = Some(repository);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_video_task_repository_for_tests<T>(repository: Arc<T>) -> Self
|
||||
where
|
||||
T: VideoTaskRepository + 'static,
|
||||
|
||||
@@ -1464,6 +1464,22 @@ pub(crate) async fn maybe_execute_sync_via_local_video_decision(
|
||||
.await
|
||||
}
|
||||
|
||||
fn supports_local_video_get(
|
||||
parts: &http::request::Parts,
|
||||
decision: &GatewayControlDecision,
|
||||
) -> bool {
|
||||
parts.method == http::Method::GET
|
||||
&& decision.route_kind.as_deref() == Some("video")
|
||||
&& (crate::video_tasks::resolve_video_task_read_lookup_key(
|
||||
decision.route_family.as_deref(),
|
||||
parts.uri.path(),
|
||||
)
|
||||
.is_some()
|
||||
|| (decision.route_family.as_deref() == Some("openai")
|
||||
&& crate::video_tasks::extract_openai_task_id_from_content_path(parts.uri.path())
|
||||
.is_some()))
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_execute_sync_request<'a>(
|
||||
state: &'a AppState,
|
||||
parts: &'a http::request::Parts,
|
||||
@@ -1477,7 +1493,7 @@ pub(crate) fn maybe_execute_sync_request<'a>(
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
if parts.method != http::Method::POST {
|
||||
if parts.method != http::Method::POST && !supports_local_video_get(parts, decision) {
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
return maybe_execute_sync_local_path(state, parts, body_bytes, trace_id, decision)
|
||||
@@ -1490,6 +1506,7 @@ pub(crate) fn maybe_execute_sync_request<'a>(
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
&& parts.method != http::Method::POST
|
||||
&& !supports_local_video_get(parts, decision)
|
||||
{
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
@@ -1511,7 +1528,7 @@ pub(crate) fn maybe_execute_stream_request<'a>(
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
if parts.method != http::Method::POST {
|
||||
if parts.method != http::Method::POST && !supports_local_video_get(parts, decision) {
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
return maybe_execute_stream_local_path(state, parts, body_bytes, trace_id, decision)
|
||||
@@ -1524,6 +1541,7 @@ pub(crate) fn maybe_execute_stream_request<'a>(
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
&& parts.method != http::Method::POST
|
||||
&& !supports_local_video_get(parts, decision)
|
||||
{
|
||||
return Ok(LocalExecutionRequestOutcome::NoPath);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ fn request_has_execution_runtime_via_guard(headers: &HeaderMap) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn frontdoor_self_loop_public_ai_path(path: &str) -> bool {
|
||||
let path = path
|
||||
.strip_prefix("/openai")
|
||||
.filter(|p| *p == "/v1/videos" || p.starts_with("/v1/videos/"))
|
||||
.unwrap_or(path);
|
||||
matches!(
|
||||
path,
|
||||
"/v1/messages"
|
||||
|
||||
@@ -13,7 +13,7 @@ pub(crate) fn openai_image_provider_max_generation_count(provider_type: &str) ->
|
||||
GROK_OPENAI_IMAGE_MAX_GENERATION_COUNT
|
||||
} else if matches!(
|
||||
provider_type.trim().to_ascii_lowercase().as_str(),
|
||||
"openai" | "codex"
|
||||
"openai" | "codex" | "xai"
|
||||
) {
|
||||
OPENAI_IMAGE_MAX_GENERATION_COUNT
|
||||
} else {
|
||||
@@ -58,6 +58,7 @@ mod tests {
|
||||
assert_eq!(openai_image_provider_max_generation_count("grok"), 4);
|
||||
assert_eq!(openai_image_provider_max_generation_count("openai"), 10);
|
||||
assert_eq!(openai_image_provider_max_generation_count("codex"), 10);
|
||||
assert_eq!(openai_image_provider_max_generation_count("xai"), 10);
|
||||
assert_eq!(openai_image_provider_max_generation_count("custom"), 1);
|
||||
assert_eq!(
|
||||
openai_image_provider_max_generation_count_for_model("openai", Some("dall-e-3")),
|
||||
|
||||
@@ -186,6 +186,8 @@ fn frontend_path_bypasses_static(path: &str) -> bool {
|
||||
"/health" | "/test-connection" | crate::constants::READYZ_PATH
|
||||
) || path.starts_with("/api/")
|
||||
|| path.starts_with("/v1/")
|
||||
|| path == "/openai/v1/videos"
|
||||
|| path.starts_with("/openai/v1/videos/")
|
||||
|| path.starts_with("/v1beta/")
|
||||
|| path.starts_with("/upload/")
|
||||
|| path.starts_with("/_gateway/")
|
||||
|
||||
@@ -290,6 +290,14 @@ impl provider_transport::VideoTaskTransportSnapshotLookup for AppState {
|
||||
.await
|
||||
.map_err(GatewayError::into_message)
|
||||
}
|
||||
|
||||
async fn resolve_video_task_proxy(
|
||||
&self,
|
||||
transport: &GatewayProviderTransportSnapshot,
|
||||
) -> Option<ProxySnapshot> {
|
||||
self.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -36,6 +36,7 @@ mod openai_sync_task;
|
||||
mod registry_poller;
|
||||
mod routing;
|
||||
mod stream;
|
||||
mod xai;
|
||||
|
||||
/// Seed online manual proxy nodes for video execution fixtures.
|
||||
///
|
||||
@@ -44,6 +45,17 @@ mod stream;
|
||||
/// the same deployment-state record; the loopback URL is never contacted when
|
||||
/// the execution-runtime override is active.
|
||||
pub(super) fn video_proxy_node_repository<I, S>(node_ids: I) -> Arc<InMemoryProxyNodeRepository>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
video_proxy_node_repository_at_url(node_ids, "http://127.0.0.1:1")
|
||||
}
|
||||
|
||||
pub(super) fn video_proxy_node_repository_at_url<I, S>(
|
||||
node_ids: I,
|
||||
proxy_url: &str,
|
||||
) -> Arc<InMemoryProxyNodeRepository>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
@@ -68,7 +80,7 @@ where
|
||||
1,
|
||||
)
|
||||
.expect("video test proxy node should build")
|
||||
.with_manual_proxy_fields(Some("http://127.0.0.1:1".to_string()), None, None)
|
||||
.with_manual_proxy_fields(Some(proxy_url.to_string()), None, None)
|
||||
.with_tunnel_generation(format!("video-test-generation-{node_id}"))
|
||||
});
|
||||
Arc::new(InMemoryProxyNodeRepository::seed(nodes))
|
||||
@@ -86,6 +98,28 @@ pub(super) fn video_provider_catalog_repository(
|
||||
endpoint_base_url: &str,
|
||||
key_id: &str,
|
||||
upstream_api_key: &str,
|
||||
) -> Arc<InMemoryProviderCatalogReadRepository> {
|
||||
video_provider_catalog_repository_with_proxy(
|
||||
provider_id,
|
||||
provider_type,
|
||||
endpoint_id,
|
||||
api_format,
|
||||
endpoint_base_url,
|
||||
key_id,
|
||||
upstream_api_key,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn video_provider_catalog_repository_with_proxy(
|
||||
provider_id: &str,
|
||||
provider_type: &str,
|
||||
endpoint_id: &str,
|
||||
api_format: &str,
|
||||
endpoint_base_url: &str,
|
||||
key_id: &str,
|
||||
upstream_api_key: &str,
|
||||
proxy: Option<serde_json::Value>,
|
||||
) -> Arc<InMemoryProviderCatalogReadRepository> {
|
||||
fn seal_bound_credential(
|
||||
provider_id: &str,
|
||||
@@ -117,7 +151,7 @@ pub(super) fn video_provider_catalog_repository(
|
||||
false,
|
||||
None,
|
||||
Some(2),
|
||||
None,
|
||||
proxy,
|
||||
Some(20.0),
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -13,7 +13,8 @@ use serde_json::json;
|
||||
|
||||
use super::{
|
||||
build_state_with_execution_runtime_override, start_server, video_provider_catalog_repository,
|
||||
AppState, VideoTaskTruthSourceMode,
|
||||
video_provider_catalog_repository_with_proxy, video_proxy_node_repository_at_url, AppState,
|
||||
VideoTaskTruthSourceMode,
|
||||
};
|
||||
|
||||
fn sample_due_openai_task(upstream_base_url: &str) -> UpsertVideoTask {
|
||||
@@ -279,13 +280,13 @@ async fn gateway_background_video_task_poller_refreshes_due_openai_task_from_rep
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let upstream_api_root = format!("{upstream_url}/v1");
|
||||
let upstream_api_root = "http://video-provider.invalid/v1".to_string();
|
||||
let repository = Arc::new(InMemoryVideoTaskRepository::default());
|
||||
repository
|
||||
.upsert(sample_due_openai_task(&upstream_api_root))
|
||||
.await
|
||||
.expect("task upsert should succeed");
|
||||
let provider_catalog_repository = video_provider_catalog_repository(
|
||||
let provider_catalog_repository = video_provider_catalog_repository_with_proxy(
|
||||
"provider-openai-video-local-1",
|
||||
"openai",
|
||||
"endpoint-openai-video-local-1",
|
||||
@@ -293,6 +294,7 @@ async fn gateway_background_video_task_poller_refreshes_due_openai_task_from_rep
|
||||
&upstream_api_root,
|
||||
"key-openai-video-local-1",
|
||||
"sk-upstream-openai-video",
|
||||
Some(json!({"enabled":true,"node_id":"poller-video-proxy"})),
|
||||
);
|
||||
|
||||
let gateway_state = AppState::new()
|
||||
@@ -302,7 +304,7 @@ async fn gateway_background_video_task_poller_refreshes_due_openai_task_from_rep
|
||||
Arc::clone(&repository),
|
||||
provider_catalog_repository,
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
),
|
||||
).attach_proxy_node_repository_for_tests(video_proxy_node_repository_at_url(["poller-video-proxy"], &upstream_url)),
|
||||
)
|
||||
.with_video_task_truth_source_mode(VideoTaskTruthSourceMode::RustAuthoritative)
|
||||
.with_video_task_poller_config(std::time::Duration::from_millis(25), 8);
|
||||
|
||||
@@ -14,8 +14,7 @@ use crate::constants::{
|
||||
use super::{build_router, start_server};
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_locally_denies_video_control_sync_even_with_opt_in_headers_when_execution_runtime_missing(
|
||||
) {
|
||||
async fn gateway_hides_video_task_from_unauthenticated_caller_with_opt_in_headers() {
|
||||
let execute_hits = Arc::new(Mutex::new(0usize));
|
||||
let execute_hits_clone = Arc::clone(&execute_hits);
|
||||
let public_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -66,13 +65,9 @@ async fn gateway_locally_denies_video_control_sync_even_with_opt_in_headers_when
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(payload, crate::video_tasks::not_found_body());
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -81,8 +76,7 @@ async fn gateway_locally_denies_video_control_sync_even_with_opt_in_headers_when
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_locally_denies_video_control_sync_without_opt_in_header_when_execution_runtime_missing(
|
||||
) {
|
||||
async fn gateway_hides_video_task_without_calling_public_or_control_upstream() {
|
||||
let execute_hits = Arc::new(Mutex::new(0usize));
|
||||
let execute_hits_clone = Arc::clone(&execute_hits);
|
||||
let public_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -142,13 +136,9 @@ async fn gateway_locally_denies_video_control_sync_without_opt_in_header_when_ex
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(payload, crate::video_tasks::not_found_body());
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(
|
||||
@@ -165,7 +155,7 @@ async fn gateway_locally_denies_video_control_sync_without_opt_in_header_when_ex
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_skips_video_get_control_sync_without_opt_in_header() {
|
||||
async fn gateway_hides_video_task_from_unauthenticated_caller_without_opt_in_headers() {
|
||||
let execute_hits = Arc::new(Mutex::new(0usize));
|
||||
let execute_hits_clone = Arc::clone(&execute_hits);
|
||||
let public_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -211,13 +201,9 @@ async fn gateway_skips_video_get_control_sync_without_opt_in_header() {
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
let payload: serde_json::Value = response.json().await.expect("body should parse");
|
||||
assert_eq!(payload["error"]["type"], "http_error");
|
||||
assert_eq!(
|
||||
payload["error"]["message"],
|
||||
"当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径"
|
||||
);
|
||||
assert_eq!(payload, crate::video_tasks::not_found_body());
|
||||
assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0);
|
||||
assert_eq!(*public_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
use super::*;
|
||||
use aether_data::repository::auth::{
|
||||
InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot,
|
||||
};
|
||||
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
|
||||
use aether_data::repository::candidates::InMemoryRequestCandidateRepository;
|
||||
use aether_data_contracts::repository::candidate_selection::{
|
||||
StoredMinimalCandidateSelectionRow, StoredProviderModelMapping,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn sample_auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
|
||||
StoredAuthApiKeySnapshot::new(
|
||||
user_id.to_string(),
|
||||
"video-user".to_string(),
|
||||
Some("video@example.com".to_string()),
|
||||
"user".to_string(),
|
||||
"local".to_string(),
|
||||
true,
|
||||
false,
|
||||
Some(json!(["openai"])),
|
||||
Some(json!(["openai:video"])),
|
||||
Some(json!(["video-model"])),
|
||||
api_key_id.to_string(),
|
||||
Some("default".to_string()),
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Some(60),
|
||||
Some(5),
|
||||
Some(4_102_444_800),
|
||||
Some(json!(["openai"])),
|
||||
Some(json!(["openai:video"])),
|
||||
Some(json!(["video-model"])),
|
||||
)
|
||||
.expect("auth snapshot should build")
|
||||
}
|
||||
|
||||
fn sample_candidate_row() -> StoredMinimalCandidateSelectionRow {
|
||||
StoredMinimalCandidateSelectionRow {
|
||||
provider_id: "provider-openai-video-local-1".to_string(),
|
||||
provider_name: "openai".to_string(),
|
||||
provider_type: "xai".to_string(),
|
||||
provider_priority: 10,
|
||||
provider_is_active: true,
|
||||
endpoint_id: "endpoint-openai-video-local-1".to_string(),
|
||||
endpoint_api_format: "openai:video".to_string(),
|
||||
endpoint_api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("video".to_string()),
|
||||
endpoint_is_active: true,
|
||||
key_id: "key-openai-video-local-1".to_string(),
|
||||
key_name: "prod".to_string(),
|
||||
key_auth_type: "api_key".to_string(),
|
||||
key_is_active: true,
|
||||
key_api_formats: Some(vec!["openai:video".to_string()]),
|
||||
key_allowed_models: None,
|
||||
key_capabilities: None,
|
||||
key_internal_priority: 5,
|
||||
key_global_priority_by_format: Some(json!({"openai:video": 1})),
|
||||
model_id: "model-openai-video-local-1".to_string(),
|
||||
global_model_id: "global-model-openai-video-local-1".to_string(),
|
||||
global_model_name: "video-model".to_string(),
|
||||
global_model_mappings: None,
|
||||
global_model_supports_streaming: Some(false),
|
||||
model_provider_model_name: "grok-imagine-video".to_string(),
|
||||
model_provider_model_mappings: Some(vec![StoredProviderModelMapping {
|
||||
name: "grok-imagine-video".to_string(),
|
||||
priority: 1,
|
||||
api_formats: Some(vec!["openai:video".to_string()]),
|
||||
endpoint_ids: None,
|
||||
operations: None,
|
||||
}]),
|
||||
model_supports_streaming: Some(false),
|
||||
model_is_active: true,
|
||||
model_is_available: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_video_native_and_compatibility_http_lifecycle() {
|
||||
Box::pin(assert_xai_video_http_lifecycle(Arc::new(
|
||||
InMemoryVideoTaskRepository::default(),
|
||||
)))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_video_native_and_compatibility_http_lifecycle_postgres() {
|
||||
let configured_database_url = std::env::var("AETHER_TEST_DATABASE_URL").ok();
|
||||
let managed_database = if configured_database_url.is_none() {
|
||||
Some(
|
||||
aether_testkit::ManagedPostgresServer::start()
|
||||
.await
|
||||
.expect("temporary PostgreSQL should start"),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let database_url = configured_database_url.unwrap_or_else(|| {
|
||||
managed_database
|
||||
.as_ref()
|
||||
.expect("managed test database should exist")
|
||||
.database_url()
|
||||
.to_string()
|
||||
});
|
||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.expect("test database should connect");
|
||||
aether_data::driver::postgres::run_migrations(&pool)
|
||||
.await
|
||||
.expect("test database should migrate");
|
||||
// Preserve the production column constraints and unique indexes while isolating test rows.
|
||||
sqlx::query("CREATE TEMP TABLE video_tasks (LIKE public.video_tasks INCLUDING ALL)")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("isolated video task table should be created");
|
||||
let repository =
|
||||
Arc::new(aether_data::repository::video_tasks::SqlxVideoTaskRepository::new(pool.clone()));
|
||||
Box::pin(assert_xai_video_http_lifecycle(repository)).await;
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
async fn assert_xai_video_http_lifecycle<T>(repository: Arc<T>)
|
||||
where
|
||||
T: aether_data_contracts::repository::video_tasks::VideoTaskRepository + 'static,
|
||||
{
|
||||
let static_dir = std::env::temp_dir().join(format!(
|
||||
"aether-xai-video-static-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&static_dir).unwrap();
|
||||
std::fs::write(
|
||||
static_dir.join("index.html"),
|
||||
"<html>Aether test frontend</html>",
|
||||
)
|
||||
.unwrap();
|
||||
let seen = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
// Exercise the real HTTP executor, including production method gates, instead of
|
||||
// the test execution-runtime override that used to hide rejected GET requests.
|
||||
let video_url = Arc::new(Mutex::new(String::new()));
|
||||
let runtime = Router::new()
|
||||
.route("/v1/videos/{operation}", any({
|
||||
let seen = seen.clone();
|
||||
let calls = calls.clone();
|
||||
let video_url = video_url.clone();
|
||||
move |request: Request| {
|
||||
let seen = seen.clone();
|
||||
let calls = calls.clone();
|
||||
let video_url = video_url.clone();
|
||||
async move {
|
||||
let (parts, body) = request.into_parts();
|
||||
assert_eq!(parts.headers["authorization"], "Bearer upstream-video-key");
|
||||
let bytes = to_bytes(body, usize::MAX).await.unwrap();
|
||||
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(json!(null));
|
||||
seen.lock().unwrap().push(json!({
|
||||
"method": parts.method.as_str(),
|
||||
"url": parts.uri.path(),
|
||||
"body": {"json_body": body}
|
||||
}));
|
||||
let response = if parts.method == http::Method::POST {
|
||||
json!({"request_id":"upstream-video-id", "provider_extension":{"accepted":true}})
|
||||
} else {
|
||||
assert_eq!(parts.uri.path(), "/v1/videos/upstream-video-id");
|
||||
if calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
json!({"status":"pending"})
|
||||
} else {
|
||||
json!({"status":"done", "model":"grok-imagine-video", "video":{"url":video_url.lock().unwrap().clone(), "duration":6, "respect_moderation":true}, "provider_extension":"preserved"})
|
||||
}
|
||||
};
|
||||
Json(response)
|
||||
}
|
||||
}
|
||||
}))
|
||||
.route("/test.mp4", any(|request: Request| async move {
|
||||
assert!(request.headers().get("authorization").is_none());
|
||||
assert!(request.headers().get("x-xai-token-auth").is_none());
|
||||
([("content-type", "video/mp4")], "test-video-bytes")
|
||||
}));
|
||||
let (runtime_url, runtime_handle) = start_server(runtime).await;
|
||||
let expected_video_url = format!("{runtime_url}/test.mp4");
|
||||
*video_url.lock().unwrap() = expected_video_url.clone();
|
||||
let state_factory = || {
|
||||
let auth = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![
|
||||
(
|
||||
Some(format!("{:x}", Sha256::digest(b"owner-key"))),
|
||||
sample_auth_snapshot("owner-api-key", "owner"),
|
||||
),
|
||||
(
|
||||
Some(format!("{:x}", Sha256::digest(b"foreign-key"))),
|
||||
sample_auth_snapshot("foreign-api-key", "foreign"),
|
||||
),
|
||||
]));
|
||||
let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
sample_candidate_row(),
|
||||
]));
|
||||
let catalog = video_provider_catalog_repository_with_proxy(
|
||||
"provider-openai-video-local-1",
|
||||
"xai",
|
||||
"endpoint-openai-video-local-1",
|
||||
"openai:video",
|
||||
"http://video-provider.invalid/v1",
|
||||
"key-openai-video-local-1",
|
||||
"upstream-video-key",
|
||||
Some(json!({"enabled":true,"node_id":"video-proxy"})),
|
||||
);
|
||||
AppState::new().expect("gateway should build").with_video_task_truth_source_mode(VideoTaskTruthSourceMode::RustAuthoritative).with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests(
|
||||
auth, candidates, catalog, Arc::new(InMemoryRequestCandidateRepository::default()), DEVELOPMENT_ENCRYPTION_KEY
|
||||
).attach_video_task_repository_for_tests(repository.clone())
|
||||
.attach_proxy_node_repository_for_tests(video_proxy_node_repository_at_url(["video-proxy"], &runtime_url))
|
||||
)
|
||||
};
|
||||
let router_factory =
|
||||
|| crate::attach_static_frontend(build_router_with_state(state_factory()), &static_dir);
|
||||
let (gateway_url, gateway_handle) = start_server(router_factory()).await;
|
||||
let client = reqwest::Client::new();
|
||||
assert_eq!(
|
||||
client
|
||||
.get(&gateway_url)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap(),
|
||||
"<html>Aether test frontend</html>"
|
||||
);
|
||||
for (path, native) in [
|
||||
("/v1/videos/generations", true),
|
||||
("/v1/videos", true),
|
||||
("/v1/videos/edits", true),
|
||||
("/v1/videos/extensions", true),
|
||||
("/openai/v1/videos", false),
|
||||
] {
|
||||
calls.store(0, Ordering::SeqCst);
|
||||
let body = if native {
|
||||
json!({"model":"video-model","prompt":"A cat","duration":6,"aspect_ratio":"1:1","video":{"url":"https://example.com/input.mp4"},"future_option":true})
|
||||
} else {
|
||||
json!({"model":"video-model","prompt":"A cat","seconds":"6","size":"1280x720"})
|
||||
};
|
||||
let response = client
|
||||
.post(format!("{gateway_url}{path}"))
|
||||
.bearer_auth("owner-key")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let result: serde_json::Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{path}: {result}");
|
||||
let id = result[if native { "request_id" } else { "id" }]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
assert_ne!(id, "upstream-video-id");
|
||||
if native {
|
||||
assert!(result.get("id").is_none());
|
||||
assert_eq!(result["provider_extension"]["accepted"], true);
|
||||
} else {
|
||||
assert_eq!(result["status"], "queued");
|
||||
}
|
||||
let request = seen.lock().unwrap().last().unwrap().clone();
|
||||
let suffix = if path.ends_with("/edits") {
|
||||
"edits"
|
||||
} else if path.ends_with("/extensions") {
|
||||
"extensions"
|
||||
} else {
|
||||
"generations"
|
||||
};
|
||||
assert_eq!(request["url"], format!("/v1/videos/{suffix}"));
|
||||
assert_eq!(request["body"]["json_body"]["model"], "grok-imagine-video");
|
||||
assert_eq!(request["body"]["json_body"]["duration"], 6);
|
||||
if native {
|
||||
assert_eq!(request["body"]["json_body"]["future_option"], true);
|
||||
} else {
|
||||
assert_eq!(request["body"]["json_body"]["aspect_ratio"], "16:9");
|
||||
assert_eq!(request["body"]["json_body"]["resolution"], "720p");
|
||||
assert!(request["body"]["json_body"].get("seconds").is_none());
|
||||
assert!(request["body"]["json_body"].get("size").is_none());
|
||||
}
|
||||
let query = format!(
|
||||
"{gateway_url}{}/{id}",
|
||||
if native {
|
||||
"/v1/videos"
|
||||
} else {
|
||||
"/openai/v1/videos"
|
||||
}
|
||||
);
|
||||
let before = seen.lock().unwrap().len();
|
||||
let denied = client
|
||||
.get(&query)
|
||||
.bearer_auth("foreign-key")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(denied.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(seen.lock().unwrap().len(), before);
|
||||
let denied_content = client
|
||||
.get(format!("{gateway_url}/openai/v1/videos/{id}/content"))
|
||||
.bearer_auth("foreign-key")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(denied_content.status(), StatusCode::NOT_FOUND);
|
||||
assert_eq!(seen.lock().unwrap().len(), before);
|
||||
let pending: serde_json::Value = client
|
||||
.get(&query)
|
||||
.bearer_auth("owner-key")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
pending["status"],
|
||||
if native { "pending" } else { "queued" },
|
||||
"{path}: {pending}"
|
||||
);
|
||||
let done: serde_json::Value = client
|
||||
.get(&query)
|
||||
.bearer_auth("owner-key")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(done["status"], if native { "done" } else { "completed" });
|
||||
if native {
|
||||
assert_eq!(done["video"]["respect_moderation"], true);
|
||||
assert_eq!(done["provider_extension"], "preserved");
|
||||
} else {
|
||||
assert_eq!(done["video_url"], expected_video_url);
|
||||
}
|
||||
let stored = repository
|
||||
.find(VideoTaskLookupKey::Id(id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
stored.client_api_format.as_deref(),
|
||||
Some(if native { "xai:video" } else { "openai:video" })
|
||||
);
|
||||
assert_eq!(
|
||||
stored.external_task_id.as_deref(),
|
||||
Some("upstream-video-id")
|
||||
);
|
||||
assert!(stored.request_metadata.is_none());
|
||||
assert!(stored.original_request_body.is_none());
|
||||
// A new gateway instance must reconstruct the pinned provider/credential and protocol.
|
||||
let (restart_url, restart_handle) = start_server(router_factory()).await;
|
||||
let restored: serde_json::Value = client
|
||||
.get(format!(
|
||||
"{restart_url}{}/{id}",
|
||||
if native {
|
||||
"/v1/videos"
|
||||
} else {
|
||||
"/openai/v1/videos"
|
||||
}
|
||||
))
|
||||
.bearer_auth("owner-key")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(restored["status"], done["status"]);
|
||||
if native {
|
||||
assert_eq!(restored["video"]["respect_moderation"], true);
|
||||
}
|
||||
let compat: serde_json::Value = client
|
||||
.get(format!("{restart_url}/openai/v1/videos/{id}"))
|
||||
.bearer_auth("owner-key")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(compat["status"], "completed");
|
||||
assert_eq!(compat["video_url"], expected_video_url);
|
||||
let native_view: serde_json::Value = client
|
||||
.get(format!("{restart_url}/v1/videos/{id}"))
|
||||
.bearer_auth("owner-key")
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(native_view["status"], "done");
|
||||
assert_eq!(native_view["video"]["respect_moderation"], true);
|
||||
for prefix in ["/v1/videos", "/openai/v1/videos"] {
|
||||
let content = client
|
||||
.get(format!("{restart_url}{prefix}/{id}/content"))
|
||||
.bearer_auth("owner-key")
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(content.status(), StatusCode::OK);
|
||||
assert_eq!(content.headers()["content-type"], "video/mp4");
|
||||
assert_eq!(content.bytes().await.unwrap(), "test-video-bytes");
|
||||
}
|
||||
restart_handle.abort();
|
||||
}
|
||||
let before = seen.lock().unwrap().len();
|
||||
let bad = client
|
||||
.post(format!("{gateway_url}/openai/v1/videos"))
|
||||
.bearer_auth("owner-key")
|
||||
.json(&json!({"model":"video-model","prompt":"cat","seconds":"wrong"}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bad.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(seen.lock().unwrap().len(), before);
|
||||
gateway_handle.abort();
|
||||
runtime_handle.abort();
|
||||
std::fs::remove_dir_all(&static_dir).unwrap();
|
||||
}
|
||||
@@ -11,6 +11,9 @@ use super::{
|
||||
fn rust_authoritative_service_builds_openai_cancel_follow_up_plan() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-local-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
@@ -92,6 +95,9 @@ fn rust_authoritative_service_builds_openai_cancel_follow_up_plan() {
|
||||
fn rust_authoritative_service_builds_openai_remix_follow_up_plan() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-local-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
@@ -177,6 +183,9 @@ fn rust_authoritative_service_builds_openai_remix_follow_up_plan() {
|
||||
fn rust_authoritative_service_builds_openai_delete_follow_up_plan() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-local-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
@@ -332,6 +341,9 @@ fn rust_authoritative_service_builds_gemini_cancel_follow_up_plan() {
|
||||
fn rust_authoritative_service_builds_openai_read_refresh_plan() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-local-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
@@ -407,6 +419,9 @@ fn rust_authoritative_service_builds_gemini_read_refresh_plan() {
|
||||
fn rust_authoritative_service_builds_poll_refresh_batch_for_active_tasks_only() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-active-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
@@ -428,6 +443,9 @@ fn rust_authoritative_service_builds_poll_refresh_batch_for_active_tasks_only()
|
||||
transport: sample_transport("https://api.openai.example", "openai:video"),
|
||||
}));
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-completed-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-999".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
@@ -471,6 +489,9 @@ fn file_video_task_store_persists_snapshots_across_service_rebuilds() {
|
||||
)
|
||||
.expect("file-backed service should build");
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-file-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
|
||||
@@ -10,6 +10,9 @@ use super::{
|
||||
fn rust_authoritative_service_projects_openai_status_into_local_read_response() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-local-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
@@ -93,6 +96,9 @@ fn rust_authoritative_service_projects_openai_status_into_local_read_response()
|
||||
fn rust_authoritative_service_builds_openai_content_stream_plan_from_direct_video_url() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-local-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
@@ -159,6 +165,9 @@ fn rust_authoritative_service_builds_openai_content_stream_plan_from_direct_vide
|
||||
fn rust_authoritative_service_returns_processing_content_response_for_pending_openai_task() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-local-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
|
||||
@@ -218,6 +218,9 @@ fn rust_authoritative_video_truth_source_can_background_success_report() {
|
||||
fn rust_authoritative_service_reads_openai_task_from_local_registry() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
let snapshot = LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-local-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
@@ -266,6 +269,9 @@ fn rust_authoritative_service_reads_openai_task_from_local_registry() {
|
||||
fn rust_authoritative_service_applies_cancel_and_delete_mutations() {
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-local-123".to_string(),
|
||||
upstream_task_id: "ext-video-task-123".to_string(),
|
||||
created_at_unix_ms: 1712345678,
|
||||
|
||||
@@ -872,7 +872,7 @@ mod tests {
|
||||
#[test]
|
||||
fn xai_image_refs_rewrite_openai_aliases_without_touching_chat_parts() {
|
||||
let mut body = json!({
|
||||
"model": "grok-4.6",
|
||||
"model": "grok-imagine-image",
|
||||
"prompt": "edit this",
|
||||
"image": {"image_url": "https://cdn.example/a.png"},
|
||||
"reference_images": [
|
||||
@@ -887,7 +887,7 @@ mod tests {
|
||||
}]
|
||||
});
|
||||
|
||||
apply_xai_upstream_payload_edits(&mut body, "xai", "openai:responses");
|
||||
apply_xai_upstream_payload_edits(&mut body, "xai", "openai:image");
|
||||
|
||||
assert_eq!(body["image"]["url"], "https://cdn.example/a.png");
|
||||
assert!(body["image"].get("image_url").is_none());
|
||||
|
||||
@@ -49,6 +49,10 @@ pub fn resolve_execution_runtime_stream_plan_kind_with_client_surface(
|
||||
method: &Method,
|
||||
path: &str,
|
||||
) -> Option<&'static str> {
|
||||
let path = path
|
||||
.strip_prefix("/openai")
|
||||
.filter(|p| *p == "/v1/videos" || p.starts_with("/v1/videos/"))
|
||||
.unwrap_or(path);
|
||||
if route_class != Some("ai_public") {
|
||||
return None;
|
||||
}
|
||||
@@ -181,6 +185,10 @@ pub fn resolve_execution_runtime_sync_plan_kind_with_client_surface(
|
||||
method: &Method,
|
||||
path: &str,
|
||||
) -> Option<&'static str> {
|
||||
let path = path
|
||||
.strip_prefix("/openai")
|
||||
.filter(|p| *p == "/v1/videos" || p.starts_with("/v1/videos/"))
|
||||
.unwrap_or(path);
|
||||
if route_class != Some("ai_public") {
|
||||
return None;
|
||||
}
|
||||
@@ -206,7 +214,10 @@ pub fn resolve_execution_runtime_sync_plan_kind_with_client_surface(
|
||||
if route_family == Some("openai")
|
||||
&& route_kind == Some("video")
|
||||
&& *method == Method::POST
|
||||
&& path == "/v1/videos"
|
||||
&& matches!(
|
||||
path,
|
||||
"/v1/videos" | "/v1/videos/generations" | "/v1/videos/edits" | "/v1/videos/extensions"
|
||||
)
|
||||
{
|
||||
return Some(OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ INNER JOIN LATERAL (
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'xai'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key')
|
||||
AND LOWER($3) IN ('openai:responses', 'openai:responses:compact')
|
||||
AND LOWER($3) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity')
|
||||
@@ -196,7 +196,7 @@ WHERE p.is_active = TRUE
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'xai'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key')
|
||||
AND LOWER($3) IN ('openai:responses', 'openai:responses:compact')
|
||||
AND LOWER($3) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity')
|
||||
@@ -380,7 +380,7 @@ INNER JOIN LATERAL (
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'xai'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key')
|
||||
AND LOWER($4) IN ('openai:responses', 'openai:responses:compact')
|
||||
AND LOWER($4) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity')
|
||||
@@ -472,7 +472,7 @@ WHERE p.is_active = TRUE
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'xai'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key')
|
||||
AND LOWER($4) IN ('openai:responses', 'openai:responses:compact')
|
||||
AND LOWER($4) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity')
|
||||
@@ -656,16 +656,16 @@ WHERE p.is_active = TRUE
|
||||
)
|
||||
)
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'grok'
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($6) IN ('openai:chat', 'openai:responses', 'claude:messages', 'openai:image')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'xai'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key')
|
||||
AND LOWER($6) IN ('openai:responses', 'openai:responses:compact')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'grok'
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
AND LOWER($6) IN ('openai:chat', 'openai:responses', 'claude:messages', 'openai:image')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) = 'xai'
|
||||
AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key')
|
||||
AND LOWER($6) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video')
|
||||
)
|
||||
OR (
|
||||
LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity')
|
||||
AND LOWER(BTRIM(pak.auth_type)) = 'oauth'
|
||||
@@ -1758,7 +1758,9 @@ mod tests {
|
||||
] {
|
||||
assert!(sql.contains("LOWER(BTRIM(p.provider_type)) = 'xai'"));
|
||||
assert!(sql.contains("LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key')"));
|
||||
assert!(sql.contains("'openai:responses', 'openai:responses:compact'"));
|
||||
assert!(sql.contains(
|
||||
"'openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video'"
|
||||
));
|
||||
assert!(sql.contains("'xai'"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,10 @@ fn key_auth_channel_matches(row: &StoredMinimalCandidateSelectionRow, api_format
|
||||
matches!(auth_type.as_str(), "oauth" | "bearer" | "api_key")
|
||||
&& matches!(
|
||||
api_format.as_str(),
|
||||
"openai:responses" | "openai:responses:compact"
|
||||
"openai:responses"
|
||||
| "openai:responses:compact"
|
||||
| "openai:image"
|
||||
| "openai:video"
|
||||
)
|
||||
}
|
||||
"windsurf" => {
|
||||
@@ -620,6 +623,37 @@ mod tests {
|
||||
assert_eq!(rows[0].global_model_name, "grok-4");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn includes_xai_oauth_rows_for_image_and_video_models() {
|
||||
let mut image = sample_row("provider-xai", "openai:image", "grok-imagine-image", 10);
|
||||
image.provider_type = "xai".to_string();
|
||||
image.provider_name = "xai".to_string();
|
||||
image.key_auth_type = "oauth".to_string();
|
||||
image.key_api_formats = Some(vec!["openai:image".to_string(), "openai:video".to_string()]);
|
||||
|
||||
let mut video = image.clone();
|
||||
video.endpoint_id = "endpoint-video".to_string();
|
||||
video.endpoint_api_format = "openai:video".to_string();
|
||||
video.global_model_name = "grok-imagine-video".to_string();
|
||||
video.model_provider_model_name = "grok-imagine-video".to_string();
|
||||
|
||||
let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![image, video]);
|
||||
|
||||
let image_rows = repository
|
||||
.list_for_exact_api_format("openai:image")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(image_rows.len(), 1);
|
||||
assert_eq!(image_rows[0].global_model_name, "grok-imagine-image");
|
||||
|
||||
let video_rows = repository
|
||||
.list_for_exact_api_format("openai:video")
|
||||
.await
|
||||
.expect("list should succeed");
|
||||
assert_eq!(video_rows.len(), 1);
|
||||
assert_eq!(video_rows[0].global_model_name, "grok-imagine-video");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requested_model_filter_respects_endpoint_scoped_default_mapping() {
|
||||
let mut selected = sample_row("provider-1", "openai:chat", "deepseek-v4-pro", 10);
|
||||
|
||||
@@ -615,6 +615,10 @@ pub fn preset_models_for_provider(provider_type: &str) -> Option<Vec<Value>> {
|
||||
preset_model("grok-3-mini", "xai", "Grok 3 Mini", "openai:responses"),
|
||||
preset_model("grok-3-mini-fast", "xai", "Grok 3 Mini Fast", "openai:responses"),
|
||||
preset_model("grok-composer-2.5-fast", "xai", "Grok Composer 2.5 Fast", "openai:responses"),
|
||||
preset_model("grok-imagine-image", "xai", "Grok Imagine Image", "openai:image"),
|
||||
preset_model("grok-imagine-image-quality", "xai", "Grok Imagine Image Quality", "openai:image"),
|
||||
preset_model("grok-imagine-video", "xai", "Grok Imagine Video", "openai:video"),
|
||||
preset_model("grok-imagine-video-1.5", "xai", "Grok Imagine Video 1.5", "openai:video"),
|
||||
],
|
||||
_ => return None,
|
||||
};
|
||||
@@ -2010,11 +2014,18 @@ mod tests {
|
||||
"grok-3-mini",
|
||||
"grok-3-mini-fast",
|
||||
"grok-composer-2.5-fast",
|
||||
"grok-imagine-image",
|
||||
"grok-imagine-image-quality",
|
||||
"grok-imagine-video",
|
||||
"grok-imagine-video-1.5",
|
||||
]
|
||||
);
|
||||
assert!(models.iter().all(|model| model["owned_by"] == json!("xai")));
|
||||
assert_eq!(models[0]["api_formats"], json!(["openai:responses"]));
|
||||
assert_eq!(models[10]["api_formats"], json!(["openai:image"]));
|
||||
assert_eq!(models[12]["api_formats"], json!(["openai:video"]));
|
||||
assert!(models
|
||||
.iter()
|
||||
.all(|model| model["api_formats"] == json!(["openai:responses"])));
|
||||
.any(|model| model["id"] == "grok-imagine-image"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,6 @@ aether-data-contracts.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
@@ -43,6 +43,27 @@ pub fn map_openai_stored_task_to_read_response(
|
||||
}
|
||||
|
||||
fn build_openai_stored_task_body(task: StoredVideoTask, status: VideoTaskStatus) -> Value {
|
||||
if task.client_api_format.as_deref() == Some("xai:video") {
|
||||
let mut body = json!({"status":match status {
|
||||
VideoTaskStatus::Completed => "done",
|
||||
VideoTaskStatus::Expired => "expired",
|
||||
VideoTaskStatus::Failed | VideoTaskStatus::Cancelled | VideoTaskStatus::Deleted => "failed",
|
||||
_ => "pending",
|
||||
}});
|
||||
if let Some(model) = task.model {
|
||||
body["model"] = json!(model);
|
||||
}
|
||||
if let Some(url) = task.video_url {
|
||||
body["video"] = json!({"url":url});
|
||||
if let Some(duration) = task.duration_seconds {
|
||||
body["video"]["duration"] = json!(duration);
|
||||
}
|
||||
}
|
||||
if status == VideoTaskStatus::Failed {
|
||||
body["error"] = json!({"code":sanitize_video_task_error_code(task.error_code).unwrap_or_else(|| "unknown".into()),"message":"Video generation failed"});
|
||||
}
|
||||
return body;
|
||||
}
|
||||
let mut body = json!({
|
||||
"id": task.id,
|
||||
"object": "video",
|
||||
@@ -57,6 +78,9 @@ fn build_openai_stored_task_body(task: StoredVideoTask, status: VideoTaskStatus)
|
||||
if let Some(prompt) = task.prompt {
|
||||
body["prompt"] = Value::String(prompt);
|
||||
}
|
||||
if let Some(seconds) = task.duration_seconds {
|
||||
body["seconds"] = json!(seconds.to_string());
|
||||
}
|
||||
if let Some(size) = task.size {
|
||||
body["size"] = Value::String(size);
|
||||
}
|
||||
@@ -91,21 +115,97 @@ fn map_openai_stored_task_status(status: VideoTaskStatus) -> &'static str {
|
||||
}
|
||||
|
||||
impl OpenAiVideoTaskSeed {
|
||||
pub fn uses_xai_provider(&self) -> bool {
|
||||
self.xai_provider || self.is_xai_native()
|
||||
}
|
||||
|
||||
pub fn is_xai_native(&self) -> bool {
|
||||
self.persistence.client_api_format == "xai:video"
|
||||
}
|
||||
|
||||
pub fn native_create_body_json(&self) -> Value {
|
||||
let mut body = self.native_response.clone().unwrap_or_else(|| json!({}));
|
||||
body["request_id"] = json!(self.local_task_id);
|
||||
if body.get("id").is_some() {
|
||||
body["id"] = json!(self.local_task_id);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn native_read_body_json(&self) -> Value {
|
||||
if let Some(mut body) = self.native_response.clone().filter(|body| {
|
||||
body.get("status").is_some()
|
||||
|| body.get("error").is_some()
|
||||
|| body.get("code").is_some()
|
||||
}) {
|
||||
if body.get("request_id").is_some() {
|
||||
body["request_id"] = json!(self.local_task_id);
|
||||
}
|
||||
if body.get("id").is_some() {
|
||||
body["id"] = json!(self.local_task_id);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
let mut body = json!({"status":match self.status {
|
||||
LocalVideoTaskStatus::Completed => "done",
|
||||
LocalVideoTaskStatus::Expired => "expired",
|
||||
LocalVideoTaskStatus::Failed | LocalVideoTaskStatus::Cancelled | LocalVideoTaskStatus::Deleted => "failed",
|
||||
_ => "pending",
|
||||
}});
|
||||
if let Some(model) = &self.model {
|
||||
body["model"] = json!(model);
|
||||
}
|
||||
if let Some(url) = &self.video_url {
|
||||
body["video"] = json!({"url":url});
|
||||
if let Some(duration) = self.seconds.as_deref().and_then(|v| v.parse::<u64>().ok()) {
|
||||
body["video"]["duration"] = json!(duration);
|
||||
}
|
||||
}
|
||||
if self.error_code.is_some() {
|
||||
body["error"] = json!({"code":self.error_code,"message":"Video generation failed"});
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
pub fn apply_provider_body(&mut self, provider_body: &Map<String, Value>) {
|
||||
if self.uses_xai_provider() {
|
||||
self.native_response = Some(Value::Object(provider_body.clone()));
|
||||
}
|
||||
|
||||
let raw_status = provider_body
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
self.status = match raw_status {
|
||||
"queued" => LocalVideoTaskStatus::Queued,
|
||||
"processing" => LocalVideoTaskStatus::Processing,
|
||||
"completed" => LocalVideoTaskStatus::Completed,
|
||||
"failed" => LocalVideoTaskStatus::Failed,
|
||||
"cancelled" => LocalVideoTaskStatus::Cancelled,
|
||||
// Accept xAI's native lifecycle vocabulary alongside OpenAI's fields.
|
||||
self.status = match raw_status.to_ascii_lowercase().as_str() {
|
||||
"queued" | "pending" => LocalVideoTaskStatus::Queued,
|
||||
"processing" | "in_progress" | "running" => LocalVideoTaskStatus::Processing,
|
||||
"completed" | "done" | "succeeded" | "success" => LocalVideoTaskStatus::Completed,
|
||||
"failed" | "error" => LocalVideoTaskStatus::Failed,
|
||||
"cancelled" | "canceled" => LocalVideoTaskStatus::Cancelled,
|
||||
"expired" => LocalVideoTaskStatus::Expired,
|
||||
_ => LocalVideoTaskStatus::Submitted,
|
||||
};
|
||||
let error = provider_body.get("error").filter(|value| !value.is_null());
|
||||
let error_code = provider_body
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
error
|
||||
.and_then(|value| value.get("code"))
|
||||
.and_then(Value::as_str)
|
||||
});
|
||||
// xAI may report a failed job as a 200 response with code/error only.
|
||||
if (error.is_some() || error_code.is_some())
|
||||
&& !matches!(
|
||||
self.status,
|
||||
LocalVideoTaskStatus::Cancelled | LocalVideoTaskStatus::Expired
|
||||
)
|
||||
{
|
||||
self.status = LocalVideoTaskStatus::Failed;
|
||||
}
|
||||
self.progress_percent = provider_body
|
||||
.get("progress")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -117,20 +217,35 @@ impl OpenAiVideoTaskSeed {
|
||||
});
|
||||
self.completed_at_unix_secs = provider_body.get("completed_at").and_then(Value::as_u64);
|
||||
self.expires_at_unix_secs = provider_body.get("expires_at").and_then(Value::as_u64);
|
||||
let error = provider_body.get("error").and_then(Value::as_object);
|
||||
self.error_code = sanitize_video_task_error_code(
|
||||
error
|
||||
.and_then(|value| value.get("code"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
);
|
||||
self.error_code = sanitize_video_task_error_code(error_code.map(str::to_string));
|
||||
self.error_message = None;
|
||||
self.video_url = provider_body
|
||||
.get("video_url")
|
||||
.or_else(|| provider_body.get("url"))
|
||||
.or_else(|| provider_body.get("result_url"))
|
||||
.or_else(|| {
|
||||
provider_body
|
||||
.get("video")
|
||||
.and_then(|video| video.get("url"))
|
||||
})
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
if let Some(seconds) = provider_body
|
||||
.get("seconds")
|
||||
.or_else(|| {
|
||||
provider_body
|
||||
.get("video")
|
||||
.and_then(|video| video.get("duration"))
|
||||
})
|
||||
.filter(|value| value.is_string() || value.is_number())
|
||||
{
|
||||
self.seconds = Some(
|
||||
seconds
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| seconds.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_content_stream_action(
|
||||
@@ -239,6 +354,9 @@ impl OpenAiVideoTaskSeed {
|
||||
}
|
||||
|
||||
pub fn client_body_json(&self) -> Value {
|
||||
if self.is_xai_native() {
|
||||
return self.native_read_body_json();
|
||||
}
|
||||
let mut body = json!({
|
||||
"id": self.local_task_id,
|
||||
"object": "video",
|
||||
@@ -259,6 +377,9 @@ impl OpenAiVideoTaskSeed {
|
||||
if let Some(seconds) = &self.seconds {
|
||||
body["seconds"] = Value::String(seconds.clone());
|
||||
}
|
||||
if let Some(video_url) = &self.video_url {
|
||||
body["video_url"] = Value::String(video_url.clone());
|
||||
}
|
||||
if let Some(remixed_from_video_id) = &self.remixed_from_video_id {
|
||||
body["remixed_from_video_id"] = Value::String(remixed_from_video_id.clone());
|
||||
}
|
||||
@@ -357,12 +478,20 @@ impl OpenAiVideoTaskSeed {
|
||||
}
|
||||
|
||||
pub fn build_get_follow_up_plan(&self, trace_id: &str) -> Option<ExecutionPlan> {
|
||||
if !matches!(
|
||||
let refreshable = matches!(
|
||||
self.status,
|
||||
LocalVideoTaskStatus::Submitted
|
||||
| LocalVideoTaskStatus::Queued
|
||||
| LocalVideoTaskStatus::Processing
|
||||
) {
|
||||
) || (self.uses_xai_provider()
|
||||
&& self.native_response.is_none()
|
||||
&& matches!(
|
||||
self.status,
|
||||
LocalVideoTaskStatus::Completed
|
||||
| LocalVideoTaskStatus::Failed
|
||||
| LocalVideoTaskStatus::Expired
|
||||
));
|
||||
if !refreshable {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -573,7 +702,12 @@ impl OpenAiVideoTaskSeed {
|
||||
};
|
||||
let mut record = UpsertVideoTask {
|
||||
id: self.local_task_id.clone(),
|
||||
short_id: None,
|
||||
// The production schema requires a unique, non-null short_id (at most 16 chars).
|
||||
// Derive it deterministically so repeated capture and legacy snapshot reloads agree.
|
||||
short_id: Some(self.local_short_id.clone().unwrap_or_else(|| {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(self.local_task_id.as_bytes()))[..16].to_string()
|
||||
})),
|
||||
request_id: self.persistence.request_id.clone(),
|
||||
user_id: self.user_id.clone(),
|
||||
api_key_id: self.api_key_id.clone(),
|
||||
@@ -589,7 +723,11 @@ impl OpenAiVideoTaskSeed {
|
||||
model: self.model.clone().or_else(|| Some(String::new())),
|
||||
prompt: self.prompt.clone().or_else(|| Some(String::new())),
|
||||
original_request_body: None,
|
||||
duration_seconds: request_body_u32(&self.persistence.original_request_body, "seconds"),
|
||||
duration_seconds: self
|
||||
.seconds
|
||||
.as_deref()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.or_else(|| request_body_u32(&self.persistence.original_request_body, "seconds")),
|
||||
resolution: request_body_string(&self.persistence.original_request_body, "resolution"),
|
||||
aspect_ratio: request_body_string(
|
||||
&self.persistence.original_request_body,
|
||||
@@ -697,6 +835,9 @@ mod tests {
|
||||
#[test]
|
||||
fn builds_minimal_openai_persistence_record_without_sensitive_snapshot() {
|
||||
let seed = OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: false,
|
||||
local_task_id: "task-openai-sensitive".to_string(),
|
||||
upstream_task_id: "upstream-openai-sensitive".to_string(),
|
||||
created_at_unix_ms: 1_712_345_678,
|
||||
@@ -747,6 +888,12 @@ mod tests {
|
||||
|
||||
let record = seed.to_upsert_record();
|
||||
|
||||
let short_id = record
|
||||
.short_id
|
||||
.as_deref()
|
||||
.expect("database short_id is required");
|
||||
assert_eq!(short_id.len(), 16);
|
||||
assert_eq!(seed.to_upsert_record().short_id, record.short_id);
|
||||
assert_eq!(record.error_code.as_deref(), Some("provider_error"));
|
||||
assert!(record.original_request_body.is_none());
|
||||
assert!(record.progress_message.is_none());
|
||||
@@ -759,6 +906,8 @@ mod tests {
|
||||
|
||||
let mut stored = record.into_stored();
|
||||
stored.status = VideoTaskStatus::Completed;
|
||||
// Migrated tasks can already have a short ID unrelated to the derived ID.
|
||||
stored.short_id = Some("legacy-short-id".to_string());
|
||||
let snapshot =
|
||||
LocalVideoTaskSnapshot::from_stored_task_with_transport(&stored, seed.transport)
|
||||
.expect("stored task should reconstruct with current transport");
|
||||
@@ -766,6 +915,21 @@ mod tests {
|
||||
panic!("expected OpenAI snapshot");
|
||||
};
|
||||
assert_eq!(restored.prompt, stored.prompt);
|
||||
assert_eq!(restored.to_upsert_record().short_id, stored.short_id);
|
||||
let mut embedded = stored.clone();
|
||||
let mut legacy_snapshot =
|
||||
serde_json::to_value(LocalVideoTaskSnapshot::OpenAi(restored.clone())).unwrap();
|
||||
legacy_snapshot["OpenAi"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("local_short_id");
|
||||
embedded.request_metadata = Some(json!({"rust_local_snapshot": legacy_snapshot}));
|
||||
let embedded_snapshot = LocalVideoTaskSnapshot::from_stored_task(&embedded)
|
||||
.expect("legacy embedded snapshot should hydrate");
|
||||
assert_eq!(
|
||||
embedded_snapshot.to_upsert_record().short_id,
|
||||
stored.short_id
|
||||
);
|
||||
assert_eq!(restored.to_upsert_record().video_url, stored.video_url);
|
||||
let Some(LocalVideoTaskContentAction::StreamPlan(plan)) =
|
||||
restored.build_content_stream_action(None, "trace-download")
|
||||
|
||||
@@ -8,7 +8,9 @@ use uuid::Uuid;
|
||||
use crate::{LocalVideoTaskRegistryMutation, LocalVideoTaskStatus, VideoTaskTruthSourceMode};
|
||||
|
||||
pub fn extract_openai_task_id_from_path(path: &str) -> Option<&str> {
|
||||
let suffix = path.strip_prefix("/v1/videos/")?;
|
||||
let suffix = path
|
||||
.strip_prefix("/v1/videos/")
|
||||
.or_else(|| path.strip_prefix("/openai/v1/videos/"))?;
|
||||
if suffix.is_empty()
|
||||
|| suffix.contains('/')
|
||||
|| suffix.ends_with(":cancel")
|
||||
@@ -29,21 +31,27 @@ pub fn extract_gemini_short_id_from_path(path: &str) -> Option<&str> {
|
||||
}
|
||||
|
||||
pub fn extract_openai_task_id_from_cancel_path(path: &str) -> Option<&str> {
|
||||
let suffix = path.strip_prefix("/v1/videos/")?;
|
||||
let suffix = path
|
||||
.strip_prefix("/v1/videos/")
|
||||
.or_else(|| path.strip_prefix("/openai/v1/videos/"))?;
|
||||
suffix
|
||||
.strip_suffix("/cancel")
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub fn extract_openai_task_id_from_remix_path(path: &str) -> Option<&str> {
|
||||
let suffix = path.strip_prefix("/v1/videos/")?;
|
||||
let suffix = path
|
||||
.strip_prefix("/v1/videos/")
|
||||
.or_else(|| path.strip_prefix("/openai/v1/videos/"))?;
|
||||
suffix
|
||||
.strip_suffix("/remix")
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub fn extract_openai_task_id_from_content_path(path: &str) -> Option<&str> {
|
||||
let suffix = path.strip_prefix("/v1/videos/")?;
|
||||
let suffix = path
|
||||
.strip_prefix("/v1/videos/")
|
||||
.or_else(|| path.strip_prefix("/openai/v1/videos/"))?;
|
||||
suffix
|
||||
.strip_suffix("/content")
|
||||
.filter(|value| !value.is_empty())
|
||||
|
||||
@@ -73,7 +73,7 @@ async fn read_openai_video_task_response(
|
||||
}
|
||||
None => state.find_stored_video_task(lookup).await?,
|
||||
};
|
||||
let Some(task) = task else {
|
||||
let Some(mut task) = task else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -81,6 +81,9 @@ async fn read_openai_video_task_response(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if request_path.starts_with("/openai/v1/videos/") {
|
||||
task.client_api_format = Some("openai:video".into());
|
||||
}
|
||||
Ok(Some(map_openai_stored_task_to_read_response(task)))
|
||||
}
|
||||
|
||||
|
||||
@@ -105,13 +105,8 @@ impl VideoTaskService {
|
||||
if self.truth_source_mode != VideoTaskTruthSourceMode::RustAuthoritative {
|
||||
return None;
|
||||
}
|
||||
match route_family {
|
||||
Some("openai") => extract_openai_task_id_from_path(request_path)
|
||||
.and_then(|task_id| self.store.read_openai(task_id)),
|
||||
Some("gemini") => extract_gemini_short_id_from_path(request_path)
|
||||
.and_then(|short_id| self.store.read_gemini(short_id)),
|
||||
_ => None,
|
||||
}
|
||||
self.snapshot_for_route(route_family, request_path)
|
||||
.map(|snapshot| snapshot.read_response_for_path(request_path))
|
||||
}
|
||||
|
||||
pub fn read_response_for_user(
|
||||
@@ -126,7 +121,7 @@ impl VideoTaskService {
|
||||
let snapshot = self.snapshot_for_route(route_family, request_path)?;
|
||||
snapshot
|
||||
.belongs_to_user(user_id)
|
||||
.then(|| snapshot.read_response())
|
||||
.then(|| snapshot.read_response_for_path(request_path))
|
||||
}
|
||||
|
||||
pub fn snapshot_for_route(
|
||||
|
||||
@@ -29,6 +29,7 @@ impl LocalVideoTaskSnapshot {
|
||||
// contain stale identity fields after a task import or repair.
|
||||
match &mut snapshot {
|
||||
Self::OpenAi(seed) => {
|
||||
seed.local_short_id = task.short_id.clone();
|
||||
seed.user_id = task.user_id.clone();
|
||||
seed.api_key_id = task.api_key_id.clone();
|
||||
}
|
||||
@@ -51,6 +52,9 @@ impl LocalVideoTaskSnapshot {
|
||||
"openai:video" => {
|
||||
let upstream_task_id = non_empty_owned(task.external_task_id.as_ref())?;
|
||||
Some(Self::OpenAi(OpenAiVideoTaskSeed {
|
||||
local_short_id: task.short_id.clone(),
|
||||
native_response: None,
|
||||
xai_provider: persistence.client_api_format == "xai:video",
|
||||
local_task_id: task.id.clone(),
|
||||
upstream_task_id,
|
||||
created_at_unix_ms: task.created_at_unix_ms,
|
||||
@@ -142,6 +146,19 @@ impl LocalVideoTaskSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_response_for_path(&self, path: &str) -> LocalVideoTaskReadResponse {
|
||||
if let Self::OpenAi(seed) = self {
|
||||
let mut seed = seed.clone();
|
||||
if path.starts_with("/openai/v1/videos/") {
|
||||
seed.persistence.client_api_format = "openai:video".to_string();
|
||||
} else if path.starts_with("/v1/videos/") && seed.uses_xai_provider() {
|
||||
seed.persistence.client_api_format = "xai:video".to_string();
|
||||
}
|
||||
return Self::OpenAi(seed).read_response();
|
||||
}
|
||||
self.read_response()
|
||||
}
|
||||
|
||||
pub fn read_response(&self) -> LocalVideoTaskReadResponse {
|
||||
match self {
|
||||
Self::OpenAi(seed) => match seed.status {
|
||||
|
||||
@@ -19,14 +19,17 @@ impl LocalVideoTaskSeed {
|
||||
) -> Option<Self> {
|
||||
let transport = LocalVideoTaskTransport::from_plan(plan)?;
|
||||
let persistence = LocalVideoTaskPersistence::from_report_context(report_context, plan);
|
||||
match report_kind {
|
||||
let mut seed = match report_kind {
|
||||
"openai_video_create_sync_finalize" => {
|
||||
let upstream_id = provider_body.get("id").and_then(Value::as_str)?.trim();
|
||||
if upstream_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let upstream_id = openai_video_provider_task_id(provider_body)?;
|
||||
|
||||
Some(Self::OpenAiCreate(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: report_context
|
||||
.get("video_provider_xai")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
local_task_id: context_text(report_context, "local_task_id")
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
upstream_task_id: upstream_id.to_string(),
|
||||
@@ -37,8 +40,12 @@ impl LocalVideoTaskSeed {
|
||||
model: context_text(report_context, "model")
|
||||
.or_else(|| request_body_text(report_context, "model")),
|
||||
prompt: request_body_text(report_context, "prompt"),
|
||||
size: request_body_text(report_context, "size"),
|
||||
seconds: request_body_text(report_context, "seconds"),
|
||||
size: context_text(report_context, "video_size")
|
||||
.or_else(|| request_body_text(report_context, "size")),
|
||||
seconds: context_u64(report_context, "video_duration")
|
||||
.map(|v| v.to_string())
|
||||
.or_else(|| request_body_text(report_context, "seconds"))
|
||||
.or_else(|| request_body_text(report_context, "duration")),
|
||||
remixed_from_video_id: None,
|
||||
status: LocalVideoTaskStatus::Submitted,
|
||||
progress_percent: 0,
|
||||
@@ -52,12 +59,15 @@ impl LocalVideoTaskSeed {
|
||||
}))
|
||||
}
|
||||
"openai_video_remix_sync_finalize" => {
|
||||
let upstream_id = provider_body.get("id").and_then(Value::as_str)?.trim();
|
||||
if upstream_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let upstream_id = openai_video_provider_task_id(provider_body)?;
|
||||
|
||||
Some(Self::OpenAiRemix(OpenAiVideoTaskSeed {
|
||||
local_short_id: None,
|
||||
native_response: None,
|
||||
xai_provider: report_context
|
||||
.get("video_provider_xai")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
local_task_id: context_text(report_context, "local_task_id")
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string()),
|
||||
upstream_task_id: upstream_id.to_string(),
|
||||
@@ -68,8 +78,12 @@ impl LocalVideoTaskSeed {
|
||||
model: context_text(report_context, "model")
|
||||
.or_else(|| request_body_text(report_context, "model")),
|
||||
prompt: request_body_text(report_context, "prompt"),
|
||||
size: request_body_text(report_context, "size"),
|
||||
seconds: request_body_text(report_context, "seconds"),
|
||||
size: context_text(report_context, "video_size")
|
||||
.or_else(|| request_body_text(report_context, "size")),
|
||||
seconds: context_u64(report_context, "video_duration")
|
||||
.map(|v| v.to_string())
|
||||
.or_else(|| request_body_text(report_context, "seconds"))
|
||||
.or_else(|| request_body_text(report_context, "duration")),
|
||||
remixed_from_video_id: context_text(report_context, "task_id")
|
||||
.or_else(|| request_body_text(report_context, "remix_video_id")),
|
||||
status: LocalVideoTaskStatus::Submitted,
|
||||
@@ -110,7 +124,11 @@ impl LocalVideoTaskSeed {
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}?;
|
||||
if let Self::OpenAiCreate(task) | Self::OpenAiRemix(task) = &mut seed {
|
||||
task.apply_provider_body(provider_body);
|
||||
}
|
||||
Some(seed)
|
||||
}
|
||||
|
||||
pub fn success_report_kind(&self) -> &'static str {
|
||||
@@ -144,12 +162,28 @@ impl LocalVideoTaskSeed {
|
||||
|
||||
pub fn client_body_json(&self) -> Value {
|
||||
match self {
|
||||
Self::OpenAiCreate(seed) | Self::OpenAiRemix(seed) => seed.client_body_json(),
|
||||
Self::OpenAiCreate(seed) | Self::OpenAiRemix(seed) => {
|
||||
if seed.is_xai_native() {
|
||||
seed.native_create_body_json()
|
||||
} else {
|
||||
seed.client_body_json()
|
||||
}
|
||||
}
|
||||
Self::GeminiCreate(seed) => seed.client_body_json(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_video_provider_task_id(body: &Map<String, Value>) -> Option<&str> {
|
||||
// xAI's OpenAI-compatible video creation returns request_id instead of id.
|
||||
["id", "request_id"].into_iter().find_map(|field| {
|
||||
body.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
impl VideoTaskTruthSourceMode {
|
||||
pub fn prepare_sync_success(
|
||||
self,
|
||||
@@ -353,6 +387,234 @@ mod tests {
|
||||
resolve_local_sync_success_background_report_kind,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn xai_native_video_protocol_survives_persistence_and_preserves_provider_fields() {
|
||||
use crate::{
|
||||
LocalVideoTaskContentAction, LocalVideoTaskSnapshot, VideoTaskService,
|
||||
VideoTaskTruthSourceMode,
|
||||
};
|
||||
let mut plan =
|
||||
build_internal_finalize_video_plan("native-create", "openai:video", None).unwrap();
|
||||
plan.url = "https://api.x.ai/v1/videos/generations".into();
|
||||
plan.headers
|
||||
.insert("authorization".into(), "Bearer test-key".into());
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
let context = json!({"local_task_id":"native-local", "user_id":"owner", "model":"grok-imagine-video", "video_client_protocol":"xai", "video_duration":6});
|
||||
let success = service
|
||||
.prepare_sync_success(
|
||||
"openai_video_create_sync_finalize",
|
||||
json!({"request_id":"native-upstream", "future_field":true})
|
||||
.as_object()
|
||||
.unwrap(),
|
||||
context.as_object().unwrap(),
|
||||
&plan,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
success.client_body_json(),
|
||||
json!({"request_id":"native-local","future_field":true})
|
||||
);
|
||||
let mut snapshot = success.to_snapshot();
|
||||
let body = json!({"status":"done","video":{"url":"https://vidgen.x.ai/video.mp4","duration":6,"respect_moderation":true},"future_field":[1,2]});
|
||||
snapshot.apply_provider_body(body.as_object().unwrap());
|
||||
assert_eq!(snapshot.read_response().body_json, body);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.read_response_for_path("/openai/v1/videos/native-local")
|
||||
.body_json["status"],
|
||||
"completed"
|
||||
);
|
||||
let LocalVideoTaskSnapshot::OpenAi(seed) = &snapshot else {
|
||||
panic!("openai task expected")
|
||||
};
|
||||
let Some(LocalVideoTaskContentAction::StreamPlan(download)) =
|
||||
seed.build_content_stream_action(None, "download")
|
||||
else {
|
||||
panic!("download expected")
|
||||
};
|
||||
assert_eq!(download.url, "https://vidgen.x.ai/video.mp4");
|
||||
assert!(download.headers.is_empty());
|
||||
let stored = snapshot.to_upsert_record().into_stored();
|
||||
assert!(stored.request_metadata.is_none());
|
||||
assert_eq!(stored.client_api_format.as_deref(), Some("xai:video"));
|
||||
let restored = LocalVideoTaskSnapshot::from_stored_task_with_transport(
|
||||
&stored,
|
||||
seed.transport.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restored.read_response().body_json["status"], "done");
|
||||
service.record_snapshot(restored);
|
||||
let poll = service
|
||||
.prepare_read_refresh_sync_plan_for_user(
|
||||
Some("openai"),
|
||||
"/v1/videos/native-local",
|
||||
"owner",
|
||||
"poll",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(poll.plan.url, "https://api.x.ai/v1/videos/native-upstream");
|
||||
assert!(service
|
||||
.prepare_read_refresh_sync_plan_for_user(
|
||||
Some("openai"),
|
||||
"/v1/videos/native-local",
|
||||
"foreign",
|
||||
"poll"
|
||||
)
|
||||
.is_none());
|
||||
assert!(service.apply_read_refresh_projection(&poll, body.as_object().unwrap()));
|
||||
assert_eq!(
|
||||
service
|
||||
.read_response_for_user(Some("openai"), "/v1/videos/native-local", "owner")
|
||||
.unwrap()
|
||||
.body_json,
|
||||
body
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_video_lifecycle_creates_polls_persists_and_downloads() {
|
||||
use crate::{
|
||||
LocalVideoTaskContentAction, LocalVideoTaskSnapshot, VideoTaskService,
|
||||
VideoTaskTruthSourceMode,
|
||||
};
|
||||
for api_root in ["https://cli-chat-proxy.grok.com/v1", "https://api.x.ai/v1"] {
|
||||
let mut plan =
|
||||
build_internal_finalize_video_plan("xai-create", "openai:video", None).unwrap();
|
||||
plan.url = format!("{api_root}/videos/generations");
|
||||
plan.headers
|
||||
.insert("authorization".into(), "Bearer test-token".into());
|
||||
let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative);
|
||||
let context = json!({"local_task_id": "local-video", "model": "grok-imagine-video", "original_request_body": {"prompt": "A cat", "seconds": "6"}});
|
||||
let success = service
|
||||
.prepare_sync_success(
|
||||
"openai_video_create_sync_finalize",
|
||||
json!({"request_id": "xai-request"}).as_object().unwrap(),
|
||||
context.as_object().unwrap(),
|
||||
&plan,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(success.client_body_json()["id"], "local-video");
|
||||
assert_eq!(success.client_body_json()["status"], "queued");
|
||||
let snapshot = success.to_snapshot();
|
||||
assert_eq!(
|
||||
snapshot.to_upsert_record().external_task_id.as_deref(),
|
||||
Some("xai-request")
|
||||
);
|
||||
service.record_snapshot(snapshot.clone());
|
||||
let poll = service
|
||||
.prepare_poll_refresh_plan_for_snapshot(snapshot, "xai-poll")
|
||||
.unwrap();
|
||||
assert_eq!(poll.plan.method, "GET");
|
||||
assert_eq!(poll.plan.url, format!("{api_root}/videos/xai-request"));
|
||||
assert_eq!(
|
||||
poll.plan.headers.get("authorization"),
|
||||
plan.headers.get("authorization")
|
||||
);
|
||||
assert!(service.apply_read_refresh_projection(
|
||||
&poll,
|
||||
json!({"status": "pending"}).as_object().unwrap()
|
||||
));
|
||||
assert_eq!(
|
||||
service
|
||||
.read_response(Some("openai"), "/v1/videos/local-video")
|
||||
.unwrap()
|
||||
.body_json["status"],
|
||||
"queued"
|
||||
);
|
||||
assert!(service.apply_read_refresh_projection(&poll, json!({
|
||||
"status": "done", "video": {"url": "https://vidgen.x.ai/result.mp4", "duration": 6}
|
||||
}).as_object().unwrap()));
|
||||
let snapshot = service
|
||||
.snapshot_for_route(Some("openai"), "/v1/videos/local-video")
|
||||
.unwrap();
|
||||
assert!(!snapshot.is_active_for_refresh());
|
||||
let record = snapshot.to_upsert_record();
|
||||
assert_eq!(
|
||||
record.status,
|
||||
aether_data_contracts::repository::video_tasks::VideoTaskStatus::Completed
|
||||
);
|
||||
assert_eq!(
|
||||
record.video_url.as_deref(),
|
||||
Some("https://vidgen.x.ai/result.mp4")
|
||||
);
|
||||
assert_eq!(record.duration_seconds, Some(6));
|
||||
let response = snapshot.read_response();
|
||||
assert_eq!(response.body_json["status"], "completed");
|
||||
assert_eq!(response.body_json["progress"], 100);
|
||||
assert_eq!(
|
||||
response.body_json["video_url"],
|
||||
"https://vidgen.x.ai/result.mp4"
|
||||
);
|
||||
let LocalVideoTaskSnapshot::OpenAi(seed) = snapshot else {
|
||||
panic!("OpenAI video expected")
|
||||
};
|
||||
let Some(LocalVideoTaskContentAction::StreamPlan(download)) =
|
||||
seed.build_content_stream_action(None, "download")
|
||||
else {
|
||||
panic!("download expected")
|
||||
};
|
||||
assert_eq!(download.url, "https://vidgen.x.ai/result.mp4");
|
||||
assert!(
|
||||
download.headers.is_empty(),
|
||||
"provider credentials must not be sent to the media CDN"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_video_errors_are_terminal_even_without_a_status() {
|
||||
use crate::{LocalVideoTaskSnapshot, VideoTaskTruthSourceMode};
|
||||
let mut plan =
|
||||
build_internal_finalize_video_plan("xai-create", "openai:video", None).unwrap();
|
||||
plan.url = "https://cli-chat-proxy.grok.com/v1/videos/generations".into();
|
||||
for body in [
|
||||
json!({"code": "content_policy_violation", "error": "Rejected"}),
|
||||
json!({"error": {"code": "content_policy_violation", "message": "Rejected"}}),
|
||||
json!({"status": "failed", "error": "Rejected"}),
|
||||
] {
|
||||
let mut snapshot = VideoTaskTruthSourceMode::RustAuthoritative
|
||||
.prepare_sync_success(
|
||||
"openai_video_create_sync_finalize",
|
||||
json!({"request_id": "xai-request"}).as_object().unwrap(),
|
||||
&Default::default(),
|
||||
&plan,
|
||||
)
|
||||
.unwrap()
|
||||
.to_snapshot();
|
||||
snapshot.apply_provider_body(body.as_object().unwrap());
|
||||
assert!(!snapshot.is_active_for_refresh());
|
||||
assert_eq!(snapshot.read_response().body_json["status"], "failed");
|
||||
let LocalVideoTaskSnapshot::OpenAi(seed) = snapshot else {
|
||||
panic!("OpenAI video expected")
|
||||
};
|
||||
assert!(seed.error_message.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_video_id_takes_precedence_over_xai_alias() {
|
||||
assert_eq!(
|
||||
super::openai_video_provider_task_id(
|
||||
json!({"id": "openai-id", "request_id": "trace-id"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
),
|
||||
Some("openai-id")
|
||||
);
|
||||
assert_eq!(
|
||||
super::openai_video_provider_task_id(
|
||||
json!({"id": " ", "request_id": "xai-id"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
),
|
||||
Some("xai-id")
|
||||
);
|
||||
assert_eq!(
|
||||
super::openai_video_provider_task_id(json!({"request_id": " "}).as_object().unwrap()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_local_sync_finalize_read_response_for_supported_video_finalize_kinds() {
|
||||
let delete_response = build_local_sync_finalize_read_response(
|
||||
|
||||
@@ -71,8 +71,16 @@ impl LocalVideoTaskPersistence {
|
||||
.unwrap_or_else(|| plan.request_id.clone()),
|
||||
username: context_text(report_context, "username"),
|
||||
api_key_name: context_text(report_context, "api_key_name"),
|
||||
client_api_format: context_text(report_context, "client_api_format")
|
||||
.unwrap_or_else(|| plan.client_api_format.clone()),
|
||||
client_api_format: if report_context
|
||||
.get("video_client_protocol")
|
||||
.and_then(Value::as_str)
|
||||
== Some("xai")
|
||||
{
|
||||
"xai:video".to_string()
|
||||
} else {
|
||||
context_text(report_context, "client_api_format")
|
||||
.unwrap_or_else(|| plan.client_api_format.clone())
|
||||
},
|
||||
provider_api_format: context_text(report_context, "provider_api_format")
|
||||
.unwrap_or_else(|| plan.provider_api_format.clone()),
|
||||
original_request_body: report_context
|
||||
|
||||
@@ -201,6 +201,13 @@ pub struct LocalVideoTaskPersistence {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OpenAiVideoTaskSeed {
|
||||
/// Preserve existing database identity; older snapshots derive it from the local task ID.
|
||||
#[serde(default)]
|
||||
pub local_short_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub native_response: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub xai_provider: bool,
|
||||
pub local_task_id: String,
|
||||
pub upstream_task_id: String,
|
||||
pub created_at_unix_ms: u64,
|
||||
|
||||
@@ -43,15 +43,105 @@ Quota refresh reads `/user` and `/billing?format=credits` and stores a structure
|
||||
usage snapshot. A prepaid balance keeps an account selectable after the weekly
|
||||
allowance is exhausted. API-key accounts skip the subscription billing surface.
|
||||
|
||||
## Images and videos
|
||||
|
||||
OAuth media requests default to `https://cli-chat-proxy.grok.com/v1`; API-key or
|
||||
`using_api=true` requests default to `https://api.x.ai/v1`. Explicit custom gateways
|
||||
are preserved. Compact remains on the official endpoint. CLI identity headers are
|
||||
applied to media requests and restored when a persisted video task's polling transport
|
||||
is reconstructed.
|
||||
|
||||
Aether's OpenAI-compatible task parser accepts xAI's `request_id` creation field,
|
||||
status aliases such as `pending` and `done`, nested `video.url` and `video.duration`,
|
||||
and failure payloads containing `code` / `error` without a status. Existing OpenAI
|
||||
`id` takes precedence. The client receives Aether's local task ID; polling uses the
|
||||
upstream task ID and selected credential. Completed video downloads use the returned
|
||||
media URL without forwarding provider authentication headers to the media host.
|
||||
|
||||
### Public video protocols
|
||||
|
||||
The xAI provider supports two video surfaces:
|
||||
|
||||
| Operation | xAI native | OpenAI compatible |
|
||||
| --- | --- | --- |
|
||||
| Create | `POST /v1/videos/generations` | `POST /openai/v1/videos` |
|
||||
| Edit / extend | `POST /v1/videos/edits`, `POST /v1/videos/extensions` | — |
|
||||
| Retrieve | `GET /v1/videos/{request_id}` | `GET /openai/v1/videos/{id}` |
|
||||
| Download | use the returned `video.url` | `GET /openai/v1/videos/{id}/content` |
|
||||
|
||||
For xAI, `POST /v1/videos` is a native creation alias. Other providers retain
|
||||
Aether's existing OpenAI-compatible `/v1/videos` behavior. xAI callers using
|
||||
OpenAI `seconds` / `size` parameters must use `/openai/v1/videos`. The adapter
|
||||
maps these to numeric `duration`, `aspect_ratio`, and `resolution`; it also adapts
|
||||
image references. This implementation defaults to 4 seconds, portrait, and 720p,
|
||||
clamps `duration` to 1-15, and validates inputs. Explicit native requests retain
|
||||
native parameters and additional provider fields.
|
||||
|
||||
Default xAI creation targets `/videos/generations` on the selected upstream host.
|
||||
Explicit custom endpoint paths still take precedence. Native generation, editing,
|
||||
and extension paths only select xAI provider candidates.
|
||||
|
||||
Native creation returns `request_id`; native retrieval preserves `done`, nested
|
||||
`video.url`, and provider fields such as `respect_moderation`. The identifier is
|
||||
an opaque Aether task ID so queries remain scoped to the owning user and pinned
|
||||
to the original upstream task and credential. The explicit `/openai/v1/videos`
|
||||
surface projects `id`, `completed`, and `video_url`.
|
||||
|
||||
The task row records the native client protocol as `xai:video`, while its provider
|
||||
transport remains `openai:video`. This survives restart without storing request
|
||||
bodies or credentials. Raw native responses are cached only in memory; after
|
||||
reconstruction the gateway refreshes from the original provider to recover its
|
||||
response fields, including for completed tasks. If refreshing is unavailable,
|
||||
the stored task still provides the native status and media URL projection.
|
||||
|
||||
OpenAI/xAI task persistence supplies a stable 16-character `short_id`, as required
|
||||
by the PostgreSQL schema. Existing rows retain their original short ID across
|
||||
reconstruction, including legacy embedded snapshots. This internal identifier is
|
||||
separate from the opaque local task ID returned to clients; no schema change or
|
||||
historical row rewrite is needed.
|
||||
|
||||
Task retrieval and content downloads are admitted by the production GET execution
|
||||
gate. Reconstructed tasks resolve proxy nodes, system proxy defaults, tunnel affinity,
|
||||
and transport profiles through the same deployment resolver used for creation;
|
||||
configured proxy routes must not silently turn into direct requests after restart.
|
||||
|
||||
### Runtime configuration
|
||||
|
||||
Standalone Rust deployments must set
|
||||
`AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative` and restart the
|
||||
gateway to enable video task retrieval, polling, and content downloads. The CLI's
|
||||
legacy default is `python-sync-report`: creation can return a task ID in that mode,
|
||||
but the local task read/refresh paths are disabled and may return HTTP 503.
|
||||
|
||||
When the gateway also serves the frontend, `/openai/v1/videos` and its subpaths
|
||||
must bypass the static SPA handler and be mounted as API routes. Otherwise a
|
||||
successful-looking HTTP 200 response to a video query may contain `text/html`
|
||||
instead of the task's JSON response. The lifecycle regression includes the static
|
||||
frontend to cover this production configuration.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
The format tests cover client and hosted search choices, image-only and mixed tool
|
||||
restrictions, encrypted reasoning replay, image reference rewriting, and unchanged
|
||||
OpenAI replay restrictions. Transport tests cover OAuth/API-key/custom routing and
|
||||
the fixed-provider endpoint template. OAuth tests cover the device code lifecycle,
|
||||
token import, and batch import.
|
||||
media identity headers. Video-task tests exercise creation, polling, terminal
|
||||
projection, persistence fields, content-download planning, and status-less errors
|
||||
using local fixtures. They do not make paid generation requests.
|
||||
|
||||
The HTTP regression exercises all native creation paths and the compatibility
|
||||
prefix through the public router and candidate planner, then checks polling,
|
||||
cross-user denial, persistence, retrieval from a fresh gateway instance, and downloads
|
||||
through both prefixes without leaking authorization to the media host. It uses the
|
||||
real HTTP executor and a managed proxy node backed by a local test server, with no
|
||||
execution-runtime override. The background poller also has a real HTTP proxy-node
|
||||
regression, so production method guards and transport reconstruction are exercised.
|
||||
CI also runs the same HTTP lifecycle with the PostgreSQL repository and the
|
||||
production column constraints/indexes in an isolated temporary table. This catches
|
||||
persistence failures that the in-memory repository cannot expose. The test uses
|
||||
local `initdb`, `postgres`, and `pg_ctl` (already provided by the gateway CI job),
|
||||
or an explicit `AETHER_TEST_DATABASE_URL` pointing to an isolated test database.
|
||||
|
||||
```sh
|
||||
cargo test -p aether-ai-formats -p aether-provider-transport -p aether-oauth --lib
|
||||
cargo test -p aether-ai-formats -p aether-provider-transport -p aether-video-tasks-core --lib
|
||||
cargo test -p aether-gateway --lib xai
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user