mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 00:47:53 +00:00
grok api client
This commit is contained in:
1 parent
dd3f7638e1
commit
eeddfaa92b
35 files changed
+2208
No files matched your search
Generated
+16
@@ -4897,6 +4897,22 @@ dependencies = [
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grok_api_client"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"env_logger 0.11.9",
|
||||
"errors",
|
||||
"log",
|
||||
"reqwest 0.12.23",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"workspace-build-acceleration",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grok_consumer_client"
|
||||
version = "0.0.1"
|
||||
|
||||
@@ -14,6 +14,7 @@ members = [
|
||||
"crates/api_clients/fal_client",
|
||||
"crates/api_clients/google_drive_downloader",
|
||||
"crates/api_clients/google_sign_in",
|
||||
"crates/api_clients/grok_api_client",
|
||||
"crates/api_clients/grok_consumer_client",
|
||||
"crates/api_clients/gmicloud_client",
|
||||
"crates/api_clients/midjourney_client",
|
||||
@@ -253,6 +254,7 @@ ffmpeg_utils = { path = "crates/lib/ffmpeg_utils" }
|
||||
file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
|
||||
filesys = { path = "crates/lib/files/filesys" }
|
||||
beeble_client = { path = "crates/api_clients/beeble_client" }
|
||||
grok_api_client = { path = "crates/api_clients/grok_api_client" }
|
||||
grok_consumer_client = { path = "crates/api_clients/grok_consumer_client" }
|
||||
rootly_client = { path = "crates/api_clients/rootly_client" }
|
||||
rootly_config = { path = "crates/schema/config/rootly_config" }
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "grok_api_client"
|
||||
edition = "2024"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
|
||||
[package.metadata.cargo-udeps.ignore]
|
||||
normal = ["workspace-build-acceleration"]
|
||||
|
||||
[lib]
|
||||
name = "grok_api_client"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# Workspace hack (faster compile times)
|
||||
workspace-build-acceleration.workspace = true
|
||||
|
||||
# Internal
|
||||
errors.workspace = true
|
||||
|
||||
# External
|
||||
log.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow.workspace = true
|
||||
env_logger.workspace = true
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Holds the API key for authenticating with the Grok (xAI Imagine) API.
|
||||
///
|
||||
/// Sent on every request as `Authorization: Bearer <api_key>`.
|
||||
#[derive(Clone)]
|
||||
pub struct GrokApiKey {
|
||||
pub(crate) api_key: String,
|
||||
}
|
||||
|
||||
impl GrokApiKey {
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self { api_key }
|
||||
}
|
||||
}
|
||||
|
||||
// Debug is redacted so the key can't end up in a log line via accidental
|
||||
// `{:?}` on an enclosing args struct.
|
||||
impl std::fmt::Debug for GrokApiKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "GrokApiKey(<redacted>)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod grok_api_key;
|
||||
@@ -0,0 +1,196 @@
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
/// Convert a non-2xx HTTP response into a `GrokError`.
|
||||
///
|
||||
/// On 2xx returns `Ok(())`; on any error status returns the most-specific
|
||||
/// `GrokError` variant we can classify from the status code and (optional)
|
||||
/// response body.
|
||||
pub fn classify_grok_http_error(status_code: StatusCode, maybe_body: Option<&str>) -> Result<(), GrokError> {
|
||||
if status_code.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match status_code {
|
||||
StatusCode::UNAUTHORIZED => return Err(GrokSpecificApiError::Unauthorized.into()),
|
||||
StatusCode::PAYMENT_REQUIRED => return Err(GrokSpecificApiError::InsufficientCredits.into()),
|
||||
StatusCode::NOT_FOUND => return Err(GrokSpecificApiError::NotFound.into()),
|
||||
StatusCode::TOO_MANY_REQUESTS => return Err(GrokSpecificApiError::RateLimited.into()),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 403 is Forbidden unless the body indicates content moderation.
|
||||
if status_code == StatusCode::FORBIDDEN {
|
||||
if let Some(body) = maybe_body {
|
||||
if body_indicates_moderation(body) {
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::PromptModerated(message).into());
|
||||
}
|
||||
}
|
||||
return Err(GrokSpecificApiError::Forbidden.into());
|
||||
}
|
||||
|
||||
// 400 Bad Request is usually a malformed body or moderation rejection.
|
||||
if status_code == StatusCode::BAD_REQUEST {
|
||||
if let Some(body) = maybe_body {
|
||||
if body_indicates_moderation(body) {
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::PromptModerated(message).into());
|
||||
}
|
||||
let message = extract_xai_error_message(body).unwrap_or_else(|| body.to_string());
|
||||
return Err(GrokSpecificApiError::BadRequest(message).into());
|
||||
}
|
||||
return Err(GrokSpecificApiError::BadRequest(String::new()).into());
|
||||
}
|
||||
|
||||
// Everything else falls through to the generic catch-all.
|
||||
Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code,
|
||||
body: maybe_body.unwrap_or("").to_string(),
|
||||
}.into())
|
||||
}
|
||||
|
||||
/// xAI returns OpenAI-compatible error envelopes:
|
||||
///
|
||||
/// ```json
|
||||
/// { "error": { "code": "...", "message": "..." } }
|
||||
/// ```
|
||||
///
|
||||
/// Extract `error.message` if present.
|
||||
fn extract_xai_error_message(body: &str) -> Option<String> {
|
||||
let parsed: serde_json::Value = serde_json::from_str(body).ok()?;
|
||||
parsed.get("error")?.get("message")?.as_str().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Heuristic: does this response body mention content moderation? xAI's
|
||||
/// safety filter rejections include words like "moderation", "safety",
|
||||
/// "content_policy", or "blocked".
|
||||
fn body_indicates_moderation(body: &str) -> bool {
|
||||
let lower = body.to_lowercase();
|
||||
lower.contains("moderat") // moderation / moderated
|
||||
|| lower.contains("safety")
|
||||
|| lower.contains("content_policy")
|
||||
|| lower.contains("content policy")
|
||||
|| lower.contains("blocked by")
|
||||
|| lower.contains("disallowed")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Success ──
|
||||
|
||||
#[test]
|
||||
fn ok_200_returns_ok() {
|
||||
assert!(classify_grok_http_error(StatusCode::OK, None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_201_returns_ok() {
|
||||
assert!(classify_grok_http_error(StatusCode::CREATED, Some("{}")).is_ok());
|
||||
}
|
||||
|
||||
// ── Direct status mappings ──
|
||||
|
||||
#[test]
|
||||
fn unauthorized_401() {
|
||||
let err = classify_grok_http_error(StatusCode::UNAUTHORIZED, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Unauthorized)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_required_402() {
|
||||
let err = classify_grok_http_error(StatusCode::PAYMENT_REQUIRED, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::InsufficientCredits)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_404() {
|
||||
let err = classify_grok_http_error(StatusCode::NOT_FOUND, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_429() {
|
||||
let err = classify_grok_http_error(StatusCode::TOO_MANY_REQUESTS, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::RateLimited)));
|
||||
}
|
||||
|
||||
// ── 403 Forbidden ──
|
||||
|
||||
#[test]
|
||||
fn plain_403_is_forbidden() {
|
||||
let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(r#"{"error":{"message":"Forbidden"}}"#)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::Forbidden)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_403_is_prompt_moderated() {
|
||||
let body = r#"{"error":{"message":"Content moderation blocked this prompt","code":"content_policy"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::FORBIDDEN, Some(body)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_))));
|
||||
}
|
||||
|
||||
// ── 400 Bad Request ──
|
||||
|
||||
#[test]
|
||||
fn bad_request_400_extracts_message() {
|
||||
let body = r#"{"error":{"code":"invalid_argument","message":"unknown field 'foo'"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert_eq!(msg, "unknown field 'foo'");
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_request_400_unparseable_body_falls_back_to_raw() {
|
||||
let body = "definitely not json";
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert_eq!(msg, "definitely not json");
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_request_400_no_body_is_empty_string() {
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, None).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(msg)) => {
|
||||
assert!(msg.is_empty());
|
||||
}
|
||||
other => panic!("expected BadRequest, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_400_is_prompt_moderated() {
|
||||
// Some APIs return moderation as 400 rather than 403.
|
||||
let body = r#"{"error":{"message":"Safety filter blocked this request"}}"#;
|
||||
let err = classify_grok_http_error(StatusCode::BAD_REQUEST, Some(body)).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::PromptModerated(_))));
|
||||
}
|
||||
|
||||
// ── Generic fallback ──
|
||||
|
||||
#[test]
|
||||
fn server_error_500_is_generic() {
|
||||
let err = classify_grok_http_error(StatusCode::INTERNAL_SERVER_ERROR, Some("oops")).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiGeneric(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_gateway_502_is_generic() {
|
||||
let err = classify_grok_http_error(StatusCode::BAD_GATEWAY, None).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiGeneric(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. })));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Errors that happen entirely on the client side — before or independent of
|
||||
/// the HTTP request reaching xAI's servers.
|
||||
#[derive(Debug)]
|
||||
pub enum GrokClientError {
|
||||
/// No API key is present.
|
||||
NoApiKeyPresent,
|
||||
|
||||
/// An error was encountered building the reqwest client.
|
||||
ReqwestClientError(reqwest::Error),
|
||||
|
||||
/// The request body could not be serialized to JSON.
|
||||
RequestSerializationError(serde_json::Error),
|
||||
}
|
||||
|
||||
impl Error for GrokClientError {}
|
||||
|
||||
impl Display for GrokClientError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NoApiKeyPresent => write!(f, "No API key present."),
|
||||
Self::ReqwestClientError(err) => write!(f, "Reqwest client error (during client creation): {}", err),
|
||||
Self::RequestSerializationError(err) => write!(f, "Failed to serialize request body to JSON: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use std::error::Error;
|
||||
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
/// Top-level error for the Grok API client. Sum of every failure mode any
|
||||
/// endpoint can produce.
|
||||
#[derive(Debug)]
|
||||
pub enum GrokError {
|
||||
Client(GrokClientError),
|
||||
ApiSpecific(GrokSpecificApiError),
|
||||
ApiGeneric(GrokGenericApiError),
|
||||
}
|
||||
|
||||
impl Error for GrokError {}
|
||||
|
||||
impl std::fmt::Display for GrokError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Client(e) => write!(f, "GrokClientError: {:?}", e),
|
||||
Self::ApiSpecific(e) => write!(f, "GrokSpecificApiError: {:?}", e),
|
||||
Self::ApiGeneric(e) => write!(f, "GrokGenericApiError: {:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GrokClientError> for GrokError {
|
||||
fn from(error: GrokClientError) -> Self {
|
||||
Self::Client(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GrokSpecificApiError> for GrokError {
|
||||
fn from(error: GrokSpecificApiError) -> Self {
|
||||
Self::ApiSpecific(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GrokGenericApiError> for GrokError {
|
||||
fn from(error: GrokGenericApiError) -> Self {
|
||||
Self::ApiGeneric(error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use reqwest::StatusCode;
|
||||
|
||||
/// Grab-bag for server-side responses that don't map to a `GrokSpecificApiError`.
|
||||
#[derive(Debug)]
|
||||
pub enum GrokGenericApiError {
|
||||
/// Response body didn't deserialize against the expected schema. Includes
|
||||
/// the original body so we can diagnose drift.
|
||||
SerdeResponseParseErrorWithBody(serde_json::Error, String),
|
||||
|
||||
/// An uncategorized bad HTTP response with status code and body.
|
||||
UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
/// An uncaught error from the HTTP client (e.g. network/IO).
|
||||
ReqwestError(reqwest::Error),
|
||||
}
|
||||
|
||||
impl Error for GrokGenericApiError {}
|
||||
|
||||
impl Display for GrokGenericApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::SerdeResponseParseErrorWithBody(err, body) => {
|
||||
write!(f, "Failed to parse Grok API response body: {:?}. Body: {}", err, body)
|
||||
}
|
||||
Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => {
|
||||
write!(f, "Uncategorized Grok API response: status code {}, body: {}", status_code, body)
|
||||
}
|
||||
Self::ReqwestError(err) => write!(f, "Reqwest error during Grok API call: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
/// Well-known, classifiable error responses from the xAI Imagine API.
|
||||
///
|
||||
/// Distinguishes things like "your key is bad" from generic 5xx so callers can
|
||||
/// react appropriately (retry vs. surface to user vs. alert).
|
||||
#[derive(Debug)]
|
||||
pub enum GrokSpecificApiError {
|
||||
/// 401 Unauthorized — API key is missing, invalid, or revoked.
|
||||
Unauthorized,
|
||||
|
||||
/// 402 Payment Required / insufficient credits to fulfill the request.
|
||||
InsufficientCredits,
|
||||
|
||||
/// 403 Forbidden — the API key is valid but lacks permission for this resource/model.
|
||||
Forbidden,
|
||||
|
||||
/// 404 Not Found — typically returned for an unknown `request_id` on the
|
||||
/// video status endpoint.
|
||||
NotFound,
|
||||
|
||||
/// 429 Too Many Requests — rate limit exceeded.
|
||||
RateLimited,
|
||||
|
||||
/// The prompt was rejected by xAI's content moderation.
|
||||
PromptModerated(String),
|
||||
|
||||
/// 400 Bad Request — the request body shape was invalid (e.g. missing
|
||||
/// required field, unsupported model, invalid aspect ratio).
|
||||
BadRequest(String),
|
||||
|
||||
/// The video generation/edit/extension job finished with `status:"failed"`.
|
||||
///
|
||||
/// The `code` mirrors xAI's machine-readable error code
|
||||
/// (`invalid_argument`, `permission_denied`, `failed_precondition`,
|
||||
/// `internal_error`); `message` is the human-readable description.
|
||||
VideoJobFailed {
|
||||
code: String,
|
||||
message: String,
|
||||
},
|
||||
|
||||
/// The video job expired (poll status was `"expired"`).
|
||||
VideoJobExpired,
|
||||
}
|
||||
|
||||
impl Error for GrokSpecificApiError {}
|
||||
|
||||
impl Display for GrokSpecificApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Unauthorized => write!(f, "Grok API: Unauthorized (invalid or missing API key)"),
|
||||
Self::InsufficientCredits => write!(f, "Grok API: Insufficient credits"),
|
||||
Self::Forbidden => write!(f, "Grok API: Forbidden"),
|
||||
Self::NotFound => write!(f, "Grok API: Not found"),
|
||||
Self::RateLimited => write!(f, "Grok API: Rate limited"),
|
||||
Self::PromptModerated(msg) => write!(f, "Grok API: Prompt moderated: {}", msg),
|
||||
Self::BadRequest(msg) => write!(f, "Grok API: Bad request: {}", msg),
|
||||
Self::VideoJobFailed { code, message } => {
|
||||
write!(f, "Grok API: Video job failed ({}): {}", code, message)
|
||||
}
|
||||
Self::VideoJobExpired => write!(f, "Grok API: Video job expired"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod classify_grok_http_error;
|
||||
pub mod grok_client_error;
|
||||
pub mod grok_error;
|
||||
pub mod grok_generic_api_error;
|
||||
pub mod grok_specific_api_error;
|
||||
@@ -0,0 +1,7 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_utils;
|
||||
|
||||
// Public API
|
||||
pub mod creds;
|
||||
pub mod error;
|
||||
pub mod requests;
|
||||
@@ -0,0 +1,278 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
use crate::requests::image_edit::request_types::*;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
const DEFAULT_MODEL: &str = "grok-imagine-image-quality";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImageEditArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
|
||||
/// Edit instruction. Required.
|
||||
pub prompt: String,
|
||||
|
||||
/// One or more source images. xAI supports up to 3.
|
||||
/// The output aspect ratio follows the first input image unless overridden.
|
||||
pub source_images: Vec<ImageSource>,
|
||||
|
||||
/// Model identifier. Defaults to `grok-imagine-image-quality` if `None`.
|
||||
pub model: Option<String>,
|
||||
|
||||
pub n: Option<u32>,
|
||||
pub aspect_ratio: Option<String>,
|
||||
pub resolution: Option<String>,
|
||||
pub response_format: Option<ImageEditResponseFormat>,
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// Source image — pick by URL or by file_id (from a prior /v1/files upload).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ImageSource {
|
||||
Url(String),
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageEditResponseFormat {
|
||||
Url,
|
||||
B64Json,
|
||||
}
|
||||
|
||||
impl ImageEditResponseFormat {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Url => "url",
|
||||
Self::B64Json => "b64_json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageEditSuccess {
|
||||
pub images: Vec<EditedImage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditedImage {
|
||||
pub url: Option<String>,
|
||||
pub b64_json: Option<String>,
|
||||
pub revised_prompt: Option<String>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/images/edits — edit one or more source images
|
||||
/// according to a text prompt.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/editing>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/multi-image-editing>
|
||||
pub async fn image_edit(args: ImageEditArgs) -> Result<ImageEditSuccess, GrokError> {
|
||||
if args.source_images.is_empty() {
|
||||
return Err(GrokSpecificApiError::BadRequest(
|
||||
"image_edit requires at least one source image".to_string(),
|
||||
).into());
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/images/edits", XAI_API_BASE_URL);
|
||||
let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||
|
||||
info!(
|
||||
"Grok image_edit: model={}, sources={}, aspect_ratio={:?}, resolution={:?}",
|
||||
model, args.source_images.len(), args.aspect_ratio, args.resolution
|
||||
);
|
||||
|
||||
// Single source → `image`. Multiple sources → `images`.
|
||||
let (image, images) = if args.source_images.len() == 1 {
|
||||
(Some(to_image_ref(&args.source_images[0])), None)
|
||||
} else {
|
||||
(None, Some(args.source_images.iter().map(to_image_ref).collect()))
|
||||
};
|
||||
|
||||
let request_body = ImageEditRequestBody {
|
||||
prompt: args.prompt,
|
||||
image,
|
||||
images,
|
||||
model: Some(model),
|
||||
n: args.n,
|
||||
aspect_ratio: args.aspect_ratio,
|
||||
resolution: args.resolution,
|
||||
response_format: args.response_format.map(|f| f.as_str().to_string()),
|
||||
user: args.user,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok image_edit response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: ImageEditResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(ImageEditSuccess {
|
||||
images: parsed.data.into_iter().map(|d| EditedImage {
|
||||
url: d.url,
|
||||
b64_json: d.b64_json,
|
||||
revised_prompt: d.revised_prompt,
|
||||
}).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn to_image_ref(source: &ImageSource) -> ImageRef {
|
||||
match source {
|
||||
ImageSource::Url(u) => ImageRef { url: Some(u.clone()), file_id: None },
|
||||
ImageSource::FileId(id) => ImageRef { url: None, file_id: Some(id.clone()) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// ── Shape tests ──
|
||||
|
||||
#[test]
|
||||
fn single_source_serializes_as_image_field() {
|
||||
let body = ImageEditRequestBody {
|
||||
prompt: "make it sepia".to_string(),
|
||||
image: Some(ImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }),
|
||||
images: None,
|
||||
model: Some("grok-imagine-image-quality".to_string()),
|
||||
n: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"image\":{"));
|
||||
assert!(!json.contains("\"images\""));
|
||||
assert!(json.contains("\"url\":\"https://example.com/a.png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_source_serializes_as_images_array() {
|
||||
let body = ImageEditRequestBody {
|
||||
prompt: "combine these".to_string(),
|
||||
image: None,
|
||||
images: Some(vec![
|
||||
ImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None },
|
||||
ImageRef { url: None, file_id: Some("file_xyz".to_string()) },
|
||||
]),
|
||||
model: None,
|
||||
n: None,
|
||||
aspect_ratio: Some("1:1".to_string()),
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"images\":["));
|
||||
assert!(!json.contains("\"image\":{"));
|
||||
assert!(json.contains("\"file_id\":\"file_xyz\""));
|
||||
assert!(json.contains("\"aspect_ratio\":\"1:1\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_sources_returns_bad_request() {
|
||||
let api_key = GrokApiKey::new("dummy".to_string());
|
||||
let result = image_edit(ImageEditArgs {
|
||||
api_key,
|
||||
prompt: "edit".to_string(),
|
||||
source_images: vec![],
|
||||
model: None,
|
||||
n: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_url() {
|
||||
let json = r#"{ "data": [ { "url": "https://imagine.x.ai/edit.png" } ] }"#;
|
||||
let parsed: ImageEditResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.data[0].url.as_deref(), Some("https://imagine.x.ai/edit.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_ref_url_only() {
|
||||
let r = ImageRef { url: Some("u".to_string()), file_id: None };
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
assert!(json.contains("\"url\":\"u\""));
|
||||
assert!(!json.contains("\"file_id\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_ref_file_id_only() {
|
||||
let r = ImageRef { url: None, file_id: Some("f".to_string()) };
|
||||
let json = serde_json::to_string(&r).unwrap();
|
||||
assert!(json.contains("\"file_id\":\"f\""));
|
||||
assert!(!json.contains("\"url\""));
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_image_edit_single() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = image_edit(ImageEditArgs {
|
||||
api_key,
|
||||
prompt: "Render this as a pencil sketch with detailed shading".to_string(),
|
||||
source_images: vec![ImageSource::Url(
|
||||
"https://docs.x.ai/assets/api-examples/images/style-realistic.png".to_string()
|
||||
)],
|
||||
model: None,
|
||||
n: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Edited {} image(s)", result.images.len());
|
||||
for (i, img) in result.images.iter().enumerate() {
|
||||
println!(" [{}] url={:?}", i, img.url);
|
||||
}
|
||||
assert!(!result.images.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod image_edit;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,57 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct ImageEditRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
/// Exactly one of `image` or `images` is sent (single vs. multi-image edit).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<ImageRef>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub images: Option<Vec<ImageRef>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub n: Option<u32>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_format: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// xAI image reference — exactly one of `url` or `file_id` is set.
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct ImageRef {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct ImageEditResponseBody {
|
||||
pub data: Vec<ImageEditResponseDatum>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct ImageEditResponseDatum {
|
||||
pub url: Option<String>,
|
||||
pub b64_json: Option<String>,
|
||||
pub revised_prompt: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::requests::image_generation::request_types::*;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
const DEFAULT_MODEL: &str = "grok-imagine-image-quality";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ImageGenerationArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
|
||||
/// Text prompt describing the image. Required.
|
||||
pub prompt: String,
|
||||
|
||||
/// Model identifier. Defaults to `grok-imagine-image-quality` if `None`.
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Number of images to generate. Server default (typically 1) if `None`.
|
||||
pub n: Option<u32>,
|
||||
|
||||
/// Aspect ratio (e.g. "1:1", "16:9", "auto"). Server default if `None`.
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Resolution (`"1k"` or `"2k"`). Server default if `None`.
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// `"url"` (default) or `"b64_json"`.
|
||||
pub response_format: Option<ImageResponseFormat>,
|
||||
|
||||
/// Optional opaque user identifier for usage attribution.
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageResponseFormat {
|
||||
Url,
|
||||
B64Json,
|
||||
}
|
||||
|
||||
impl ImageResponseFormat {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Url => "url",
|
||||
Self::B64Json => "b64_json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageGenerationSuccess {
|
||||
/// One entry per generated image. Order matches the order xAI returned.
|
||||
pub images: Vec<GeneratedImage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeneratedImage {
|
||||
/// URL to the rendered image. Present when `response_format` was `"url"`
|
||||
/// (the default). xAI URLs are time-limited.
|
||||
pub url: Option<String>,
|
||||
|
||||
/// Base64-encoded image bytes. Present when `response_format` was `"b64_json"`.
|
||||
pub b64_json: Option<String>,
|
||||
|
||||
/// xAI's revised version of the input prompt, if it edited it.
|
||||
pub revised_prompt: Option<String>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/images/generations — generate one or more images
|
||||
/// from a text prompt using xAI's Imagine API.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/model-capabilities/images/generation>
|
||||
pub async fn image_generation(args: ImageGenerationArgs) -> Result<ImageGenerationSuccess, GrokError> {
|
||||
let url = format!("{}/v1/images/generations", XAI_API_BASE_URL);
|
||||
|
||||
let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||
|
||||
info!(
|
||||
"Grok image_generation: model={}, n={:?}, aspect_ratio={:?}, resolution={:?}",
|
||||
model, args.n, args.aspect_ratio, args.resolution
|
||||
);
|
||||
|
||||
let request_body = ImageGenerationRequestBody {
|
||||
prompt: args.prompt,
|
||||
model: Some(model),
|
||||
n: args.n,
|
||||
aspect_ratio: args.aspect_ratio,
|
||||
resolution: args.resolution,
|
||||
response_format: args.response_format.map(|f| f.as_str().to_string()),
|
||||
user: args.user,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok image_generation response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: ImageGenerationResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(ImageGenerationSuccess {
|
||||
images: parsed.data.into_iter().map(|d| GeneratedImage {
|
||||
url: d.url,
|
||||
b64_json: d.b64_json,
|
||||
revised_prompt: d.revised_prompt,
|
||||
}).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// ── Shape tests (no API calls) ──
|
||||
|
||||
#[test]
|
||||
fn request_body_serializes_minimal() {
|
||||
let body = ImageGenerationRequestBody {
|
||||
prompt: "a cat".to_string(),
|
||||
model: Some("grok-imagine-image-quality".to_string()),
|
||||
n: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"a cat\""));
|
||||
assert!(json.contains("\"model\":\"grok-imagine-image-quality\""));
|
||||
// Optional fields should be omitted
|
||||
assert!(!json.contains("\"n\""));
|
||||
assert!(!json.contains("\"aspect_ratio\""));
|
||||
assert!(!json.contains("\"user\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_body_serializes_full() {
|
||||
let body = ImageGenerationRequestBody {
|
||||
prompt: "a cat".to_string(),
|
||||
model: Some("grok-imagine-image-quality".to_string()),
|
||||
n: Some(2),
|
||||
aspect_ratio: Some("16:9".to_string()),
|
||||
resolution: Some("2k".to_string()),
|
||||
response_format: Some("b64_json".to_string()),
|
||||
user: Some("user_abc".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"n\":2"));
|
||||
assert!(json.contains("\"aspect_ratio\":\"16:9\""));
|
||||
assert!(json.contains("\"resolution\":\"2k\""));
|
||||
assert!(json.contains("\"response_format\":\"b64_json\""));
|
||||
assert!(json.contains("\"user\":\"user_abc\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_url() {
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{ "url": "https://imagine.x.ai/abc.png", "revised_prompt": "a cat" }
|
||||
]
|
||||
}"#;
|
||||
let parsed: ImageGenerationResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.data.len(), 1);
|
||||
assert_eq!(parsed.data[0].url.as_deref(), Some("https://imagine.x.ai/abc.png"));
|
||||
assert!(parsed.data[0].b64_json.is_none());
|
||||
assert_eq!(parsed.data[0].revised_prompt.as_deref(), Some("a cat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_b64_json() {
|
||||
let json = r#"{
|
||||
"data": [
|
||||
{ "b64_json": "iVBORw0KGgo=" }
|
||||
]
|
||||
}"#;
|
||||
let parsed: ImageGenerationResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.data[0].b64_json.as_deref(), Some("iVBORw0KGgo="));
|
||||
assert!(parsed.data[0].url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_format_enum_round_trips() {
|
||||
assert_eq!(ImageResponseFormat::Url.as_str(), "url");
|
||||
assert_eq!(ImageResponseFormat::B64Json.as_str(), "b64_json");
|
||||
}
|
||||
|
||||
// ── Live API tests (ignored — incur cost) ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_image_generation_simple() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = image_generation(ImageGenerationArgs {
|
||||
api_key,
|
||||
prompt: "A serene mountain lake at sunrise, photorealistic".to_string(),
|
||||
model: None,
|
||||
n: None,
|
||||
aspect_ratio: Some("16:9".to_string()),
|
||||
resolution: Some("1k".to_string()),
|
||||
response_format: None,
|
||||
user: None,
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Generated {} image(s)", result.images.len());
|
||||
for (i, img) in result.images.iter().enumerate() {
|
||||
println!(" [{}] url={:?} revised_prompt={:?}", i, img.url, img.revised_prompt);
|
||||
}
|
||||
assert!(!result.images.is_empty());
|
||||
assert!(result.images[0].url.is_some(), "expected url-format response by default");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod image_generation;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,43 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct ImageGenerationRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub n: Option<u32>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub response_format: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct ImageGenerationResponseBody {
|
||||
pub data: Vec<ImageGenerationResponseDatum>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct ImageGenerationResponseDatum {
|
||||
pub url: Option<String>,
|
||||
pub b64_json: Option<String>,
|
||||
|
||||
/// xAI may revise the prompt (their docs call this "revised_prompt" in
|
||||
/// OpenAI-compat responses). Optional, included if present.
|
||||
pub revised_prompt: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod image_edit;
|
||||
pub mod image_generation;
|
||||
pub mod video_edit;
|
||||
pub mod video_extension;
|
||||
pub mod video_generation;
|
||||
pub mod video_status;
|
||||
|
||||
pub(crate) mod xai_host;
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod video_edit;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct VideoEditRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
pub video: VideoSourceRef,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Optional `output.upload_url`. See `video_generation` for the
|
||||
/// docs-vs-REST-spec discrepancy about whether this is required.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<VideoEditOutput>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct VideoSourceRef {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct VideoEditOutput {
|
||||
pub upload_url: String,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoEditResponseBody {
|
||||
pub request_id: String,
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::requests::video_edit::request_types::*;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
const DEFAULT_MODEL: &str = "grok-imagine-video";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoEditArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
|
||||
/// Edit instruction. Required.
|
||||
pub prompt: String,
|
||||
|
||||
/// Source video to modify.
|
||||
pub source_video: VideoSource,
|
||||
|
||||
/// Model identifier. Defaults to `grok-imagine-video`.
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Optional presigned PUT URL. See `video_generation` for the
|
||||
/// docs-vs-REST-spec discrepancy.
|
||||
pub upload_url: Option<String>,
|
||||
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoSource {
|
||||
Url(String),
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoEditSuccess {
|
||||
/// Use this with `video_status` to poll for completion.
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/videos/edits — modify an existing video based on
|
||||
/// a text prompt. Asynchronous; poll `video_status` for completion.
|
||||
///
|
||||
/// xAI states the `duration`, `aspectRatio`, and `resolution` parameters are
|
||||
/// ignored for video edits — the output mirrors the source video.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/model-capabilities/video/editing>
|
||||
pub async fn video_edit(args: VideoEditArgs) -> Result<VideoEditSuccess, GrokError> {
|
||||
let url = format!("{}/v1/videos/edits", XAI_API_BASE_URL);
|
||||
let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||
|
||||
info!("Grok video_edit: model={}", model);
|
||||
|
||||
let request_body = VideoEditRequestBody {
|
||||
prompt: args.prompt,
|
||||
video: to_video_source_ref(&args.source_video),
|
||||
model: Some(model),
|
||||
output: args.upload_url.map(|upload_url| VideoEditOutput { upload_url }),
|
||||
user: args.user,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_edit response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoEditResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(VideoEditSuccess { request_id: parsed.request_id })
|
||||
}
|
||||
|
||||
fn to_video_source_ref(source: &VideoSource) -> VideoSourceRef {
|
||||
match source {
|
||||
VideoSource::Url(u) => VideoSourceRef { url: Some(u.clone()), file_id: None },
|
||||
VideoSource::FileId(id) => VideoSourceRef { url: None, file_id: Some(id.clone()) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// ── Shape tests ──
|
||||
|
||||
#[test]
|
||||
fn body_serializes_url_source() {
|
||||
let body = VideoEditRequestBody {
|
||||
prompt: "make it stormy".to_string(),
|
||||
video: VideoSourceRef { url: Some("https://example.com/v.mp4".to_string()), file_id: None },
|
||||
model: Some("grok-imagine-video".to_string()),
|
||||
output: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"make it stormy\""));
|
||||
assert!(json.contains("\"video\":{\"url\":\"https://example.com/v.mp4\"}"));
|
||||
assert!(json.contains("\"model\":\"grok-imagine-video\""));
|
||||
assert!(!json.contains("\"output\""));
|
||||
assert!(!json.contains("\"file_id\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_serializes_file_id_source_with_upload_url() {
|
||||
let body = VideoEditRequestBody {
|
||||
prompt: "p".to_string(),
|
||||
video: VideoSourceRef { url: None, file_id: Some("file_v".to_string()) },
|
||||
model: None,
|
||||
output: Some(VideoEditOutput { upload_url: "https://r2.example.com/put".to_string() }),
|
||||
user: Some("u".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}"));
|
||||
assert!(json.contains("\"output\":{\"upload_url\":\"https://r2.example.com/put\"}"));
|
||||
assert!(json.contains("\"user\":\"u\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{ "request_id": "0199c33d-3afa-7000-b400-deadbeefcafe" }"#;
|
||||
let parsed: VideoEditResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.request_id, "0199c33d-3afa-7000-b400-deadbeefcafe");
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_video_edit() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
// Replace with a real publicly-reachable mp4 URL when running.
|
||||
let result = video_edit(VideoEditArgs {
|
||||
api_key,
|
||||
prompt: "Change the lighting to golden hour".to_string(),
|
||||
source_video: VideoSource::Url(
|
||||
"https://docs.x.ai/assets/api-examples/videos/edit-source.mp4".to_string()
|
||||
),
|
||||
model: None,
|
||||
upload_url: None,
|
||||
user: None,
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Edit request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod video_extension;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,44 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct VideoExtensionRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
pub video: VideoExtensionSourceRef,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Length of the *extension only*, not the total output. xAI default is 6
|
||||
/// seconds; range 1–10.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u32>,
|
||||
|
||||
/// Optional `output.upload_url`. See `video_generation` for the
|
||||
/// docs-vs-REST-spec discrepancy.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<VideoExtensionOutput>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct VideoExtensionSourceRef {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct VideoExtensionOutput {
|
||||
pub upload_url: String,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoExtensionResponseBody {
|
||||
pub request_id: String,
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::requests::video_extension::request_types::*;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
const DEFAULT_MODEL: &str = "grok-imagine-video";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoExtensionArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
|
||||
/// Prompt describing what should happen in the extension.
|
||||
pub prompt: String,
|
||||
|
||||
/// Source video to extend.
|
||||
pub source_video: VideoExtensionSource,
|
||||
|
||||
/// Model identifier. Defaults to `grok-imagine-video`.
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Length of the *extension only*, not the total output. xAI default is 6
|
||||
/// seconds; range 1–10.
|
||||
pub duration: Option<u32>,
|
||||
|
||||
/// Optional presigned PUT URL.
|
||||
pub upload_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoExtensionSource {
|
||||
Url(String),
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoExtensionSuccess {
|
||||
/// Use this with `video_status` to poll for completion.
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/videos/extensions — extend an existing video with
|
||||
/// additional generated content.
|
||||
///
|
||||
/// Asynchronous; poll `video_status` for completion.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/model-capabilities/video/extension>
|
||||
pub async fn video_extension(args: VideoExtensionArgs) -> Result<VideoExtensionSuccess, GrokError> {
|
||||
let url = format!("{}/v1/videos/extensions", XAI_API_BASE_URL);
|
||||
let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||
|
||||
info!("Grok video_extension: model={}, duration={:?}", model, args.duration);
|
||||
|
||||
let request_body = VideoExtensionRequestBody {
|
||||
prompt: args.prompt,
|
||||
video: to_extension_source_ref(&args.source_video),
|
||||
model: Some(model),
|
||||
duration: args.duration,
|
||||
output: args.upload_url.map(|upload_url| VideoExtensionOutput { upload_url }),
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_extension response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoExtensionResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(VideoExtensionSuccess { request_id: parsed.request_id })
|
||||
}
|
||||
|
||||
fn to_extension_source_ref(source: &VideoExtensionSource) -> VideoExtensionSourceRef {
|
||||
match source {
|
||||
VideoExtensionSource::Url(u) => VideoExtensionSourceRef { url: Some(u.clone()), file_id: None },
|
||||
VideoExtensionSource::FileId(id) => VideoExtensionSourceRef { url: None, file_id: Some(id.clone()) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// ── Shape tests ──
|
||||
|
||||
#[test]
|
||||
fn body_serializes_minimal() {
|
||||
let body = VideoExtensionRequestBody {
|
||||
prompt: "keep walking".to_string(),
|
||||
video: VideoExtensionSourceRef { url: Some("https://example.com/v.mp4".to_string()), file_id: None },
|
||||
model: Some("grok-imagine-video".to_string()),
|
||||
duration: None,
|
||||
output: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"keep walking\""));
|
||||
assert!(json.contains("\"video\":{\"url\":\"https://example.com/v.mp4\"}"));
|
||||
assert!(json.contains("\"model\":\"grok-imagine-video\""));
|
||||
assert!(!json.contains("\"duration\""));
|
||||
assert!(!json.contains("\"output\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_serializes_with_duration_and_upload_url() {
|
||||
let body = VideoExtensionRequestBody {
|
||||
prompt: "p".to_string(),
|
||||
video: VideoExtensionSourceRef { url: None, file_id: Some("file_v".to_string()) },
|
||||
model: None,
|
||||
duration: Some(8),
|
||||
output: Some(VideoExtensionOutput { upload_url: "https://r2.example.com/put".to_string() }),
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"duration\":8"));
|
||||
assert!(json.contains("\"video\":{\"file_id\":\"file_v\"}"));
|
||||
assert!(json.contains("\"output\":{\"upload_url\":\"https://r2.example.com/put\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{ "request_id": "ext-abc-123" }"#;
|
||||
let parsed: VideoExtensionResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.request_id, "ext-abc-123");
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_video_extension() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
// Replace with a real publicly-reachable mp4 URL when running.
|
||||
let result = video_extension(VideoExtensionArgs {
|
||||
api_key,
|
||||
prompt: "Continue the walk down the same street".to_string(),
|
||||
source_video: VideoExtensionSource::Url(
|
||||
"https://docs.x.ai/assets/api-examples/videos/extension-source.mp4".to_string()
|
||||
),
|
||||
model: None,
|
||||
duration: Some(5),
|
||||
upload_url: None,
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Extension request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod video_generation;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,60 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
|
||||
// ── Request ──
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub(crate) struct VideoGenerationRequestBody {
|
||||
pub prompt: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Image-to-video: single source image. Mutually exclusive with
|
||||
/// `reference_images`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image: Option<VideoImageRef>,
|
||||
|
||||
/// Reference-to-video: array of reference images.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reference_images: Option<Vec<VideoImageRef>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub duration: Option<u32>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Output upload target. Per xAI REST spec this is required for production
|
||||
/// usage; per the published examples it's optional and xAI hosts the result
|
||||
/// temporarily if omitted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<VideoOutput>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// xAI image/video reference — exactly one of `url` or `file_id` is set.
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct VideoImageRef {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub file_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
pub(crate) struct VideoOutput {
|
||||
pub upload_url: String,
|
||||
}
|
||||
|
||||
// ── Response ──
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoGenerationResponseBody {
|
||||
pub request_id: String,
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
use crate::requests::video_generation::request_types::*;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
const DEFAULT_MODEL: &str = "grok-imagine-video";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoGenerationArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
|
||||
/// Text prompt. Required.
|
||||
pub prompt: String,
|
||||
|
||||
/// Model identifier. Defaults to `grok-imagine-video`.
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Image-to-video: a single source image. Mutually exclusive with
|
||||
/// `reference_images` — supplying both returns a `BadRequest` before the
|
||||
/// HTTP call.
|
||||
pub image: Option<VideoImageSource>,
|
||||
|
||||
/// Reference-to-video: zero or more reference images.
|
||||
pub reference_images: Option<Vec<VideoImageSource>>,
|
||||
|
||||
/// e.g. "16:9", "1:1", "9:16".
|
||||
pub aspect_ratio: Option<String>,
|
||||
|
||||
/// Duration in seconds (1–15). xAI default is 8.
|
||||
pub duration: Option<u32>,
|
||||
|
||||
/// `"480p"`, `"720p"`, `"1080p"`.
|
||||
pub resolution: Option<String>,
|
||||
|
||||
/// Optional presigned PUT URL where xAI should upload the rendered video.
|
||||
/// Per the REST API reference this is required; per the published curl
|
||||
/// examples it's optional. Omit unless your account requires it.
|
||||
pub upload_url: Option<String>,
|
||||
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoImageSource {
|
||||
Url(String),
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoGenerationSuccess {
|
||||
/// Use this with `video_status::video_status` to poll for completion.
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/videos/generations — start a video generation
|
||||
/// job. The video is rendered asynchronously; poll
|
||||
/// `video_status::video_status(request_id)` until it returns `done` or
|
||||
/// `failed`.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/generation>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/image-to-video>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/video/reference-to-video>
|
||||
pub async fn video_generation(args: VideoGenerationArgs) -> Result<VideoGenerationSuccess, GrokError> {
|
||||
if args.image.is_some() && args.reference_images.as_ref().is_some_and(|v| !v.is_empty()) {
|
||||
return Err(GrokSpecificApiError::BadRequest(
|
||||
"video_generation cannot combine `image` (image-to-video) with `reference_images` (reference-to-video) in the same request".to_string(),
|
||||
).into());
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/videos/generations", XAI_API_BASE_URL);
|
||||
let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||
|
||||
info!(
|
||||
"Grok video_generation: model={}, has_image={}, ref_imgs={}, aspect_ratio={:?}, duration={:?}, resolution={:?}",
|
||||
model,
|
||||
args.image.is_some(),
|
||||
args.reference_images.as_ref().map(|v| v.len()).unwrap_or(0),
|
||||
args.aspect_ratio,
|
||||
args.duration,
|
||||
args.resolution,
|
||||
);
|
||||
|
||||
let request_body = VideoGenerationRequestBody {
|
||||
prompt: args.prompt,
|
||||
model: Some(model),
|
||||
image: args.image.as_ref().map(to_video_image_ref),
|
||||
reference_images: args.reference_images.map(|v| v.iter().map(to_video_image_ref).collect()),
|
||||
aspect_ratio: args.aspect_ratio,
|
||||
duration: args.duration,
|
||||
resolution: args.resolution,
|
||||
output: args.upload_url.map(|upload_url| VideoOutput { upload_url }),
|
||||
user: args.user,
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.post(&url)
|
||||
.header("Authorization", bearer)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_generation response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoGenerationResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(VideoGenerationSuccess { request_id: parsed.request_id })
|
||||
}
|
||||
|
||||
fn to_video_image_ref(source: &VideoImageSource) -> VideoImageRef {
|
||||
match source {
|
||||
VideoImageSource::Url(u) => VideoImageRef { url: Some(u.clone()), file_id: None },
|
||||
VideoImageSource::FileId(id) => VideoImageRef { url: None, file_id: Some(id.clone()) },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
// ── Shape tests ──
|
||||
|
||||
#[test]
|
||||
fn body_serializes_text_only() {
|
||||
let body = VideoGenerationRequestBody {
|
||||
prompt: "a cat dancing".to_string(),
|
||||
model: Some("grok-imagine-video".to_string()),
|
||||
image: None,
|
||||
reference_images: None,
|
||||
aspect_ratio: Some("16:9".to_string()),
|
||||
duration: Some(5),
|
||||
resolution: Some("720p".to_string()),
|
||||
output: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"prompt\":\"a cat dancing\""));
|
||||
assert!(json.contains("\"duration\":5"));
|
||||
assert!(json.contains("\"resolution\":\"720p\""));
|
||||
assert!(!json.contains("\"image\""));
|
||||
assert!(!json.contains("\"reference_images\""));
|
||||
assert!(!json.contains("\"output\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_serializes_image_to_video() {
|
||||
let body = VideoGenerationRequestBody {
|
||||
prompt: "animate this".to_string(),
|
||||
model: None,
|
||||
image: Some(VideoImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None }),
|
||||
reference_images: None,
|
||||
aspect_ratio: None,
|
||||
duration: None,
|
||||
resolution: None,
|
||||
output: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"image\":{"));
|
||||
assert!(json.contains("\"url\":\"https://example.com/a.png\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_serializes_reference_to_video() {
|
||||
let body = VideoGenerationRequestBody {
|
||||
prompt: "<IMAGE_1> walking".to_string(),
|
||||
model: None,
|
||||
image: None,
|
||||
reference_images: Some(vec![
|
||||
VideoImageRef { url: Some("https://example.com/a.png".to_string()), file_id: None },
|
||||
VideoImageRef { url: None, file_id: Some("file_xyz".to_string()) },
|
||||
]),
|
||||
aspect_ratio: None,
|
||||
duration: None,
|
||||
resolution: None,
|
||||
output: None,
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"reference_images\":["));
|
||||
assert!(json.contains("\"file_id\":\"file_xyz\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_serializes_upload_url() {
|
||||
let body = VideoGenerationRequestBody {
|
||||
prompt: "p".to_string(),
|
||||
model: None,
|
||||
image: None,
|
||||
reference_images: None,
|
||||
aspect_ratio: None,
|
||||
duration: None,
|
||||
resolution: None,
|
||||
output: Some(VideoOutput { upload_url: "https://r2.example.com/put".to_string() }),
|
||||
user: None,
|
||||
};
|
||||
let json = serde_json::to_string(&body).unwrap();
|
||||
assert!(json.contains("\"output\":{\"upload_url\":\"https://r2.example.com/put\"}"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_plus_reference_images_returns_bad_request() {
|
||||
let api_key = GrokApiKey::new("dummy".to_string());
|
||||
let result = video_generation(VideoGenerationArgs {
|
||||
api_key,
|
||||
prompt: "x".to_string(),
|
||||
model: None,
|
||||
image: Some(VideoImageSource::Url("u".to_string())),
|
||||
reference_images: Some(vec![VideoImageSource::Url("v".to_string())]),
|
||||
aspect_ratio: None,
|
||||
duration: None,
|
||||
resolution: None,
|
||||
upload_url: None,
|
||||
user: None,
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{ "request_id": "d97415a1-5796-b7ec-379f-4e6819e08fdf" }"#;
|
||||
let parsed: VideoGenerationResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.request_id, "d97415a1-5796-b7ec-379f-4e6819e08fdf");
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key and incurs costs
|
||||
async fn live_test_video_generation_text_only() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
let result = video_generation(VideoGenerationArgs {
|
||||
api_key,
|
||||
prompt: "A glowing crystal rocket launching from Mars".to_string(),
|
||||
model: None,
|
||||
image: None,
|
||||
reference_images: None,
|
||||
aspect_ratio: Some("16:9".to_string()),
|
||||
duration: Some(5),
|
||||
resolution: Some("480p".to_string()),
|
||||
upload_url: None,
|
||||
user: None,
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Video request_id: {}", result.request_id);
|
||||
assert!(!result.request_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod video_status;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoStatusResponseBody {
|
||||
pub status: String,
|
||||
|
||||
pub progress: Option<u8>,
|
||||
|
||||
pub model: Option<String>,
|
||||
|
||||
pub video: Option<VideoStatusVideo>,
|
||||
|
||||
pub error: Option<VideoStatusError>,
|
||||
|
||||
pub usage: Option<VideoStatusUsage>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoStatusVideo {
|
||||
pub url: Option<String>,
|
||||
pub duration: Option<u32>,
|
||||
pub respect_moderation: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoStatusError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub(crate) struct VideoStatusUsage {
|
||||
pub cost_in_usd_ticks: Option<u64>,
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
use log::info;
|
||||
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
use crate::error::classify_grok_http_error::classify_grok_http_error;
|
||||
use crate::error::grok_client_error::GrokClientError;
|
||||
use crate::error::grok_error::GrokError;
|
||||
use crate::error::grok_generic_api_error::GrokGenericApiError;
|
||||
use crate::requests::video_status::request_types::*;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VideoStatusArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
|
||||
/// The `request_id` returned by a prior `video_generation`,
|
||||
/// `video_edit`, or `video_extension` call.
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum VideoStatusState {
|
||||
/// Still working.
|
||||
Pending,
|
||||
/// Finished successfully — `video.url` is populated.
|
||||
Done,
|
||||
/// xAI couldn't render this — see `VideoJobFailed` in the error path.
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoStatusSuccess {
|
||||
pub state: VideoStatusState,
|
||||
|
||||
/// 0–100, may be `None` while still queued.
|
||||
pub progress: Option<u8>,
|
||||
|
||||
/// The model that fulfilled the request.
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Populated when `state == Done`.
|
||||
pub video: Option<VideoOutputInfo>,
|
||||
|
||||
/// xAI cost reporting (in micro-dollars / "USD ticks").
|
||||
pub cost_in_usd_ticks: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VideoOutputInfo {
|
||||
/// Temporary xAI-hosted URL, or the caller-supplied upload URL once xAI has
|
||||
/// finished uploading.
|
||||
pub url: Option<String>,
|
||||
pub duration: Option<u32>,
|
||||
pub respect_moderation: Option<bool>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// GET https://api.x.ai/v1/videos/{request_id} — poll a video generation /
|
||||
/// edit / extension job for its current status.
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(VideoStatusSuccess { state: Pending, .. })` while the job is in flight
|
||||
/// - `Ok(VideoStatusSuccess { state: Done, video: Some(..), .. })` when finished
|
||||
/// - `Err(GrokSpecificApiError::VideoJobFailed { code, message })` when xAI
|
||||
/// reports the job as failed
|
||||
/// - `Err(GrokSpecificApiError::VideoJobExpired)` when the job expired
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/model-capabilities/video/generation>
|
||||
pub async fn video_status(args: VideoStatusArgs) -> Result<VideoStatusSuccess, GrokError> {
|
||||
let url = format!("{}/v1/videos/{}", XAI_API_BASE_URL, args.request_id);
|
||||
|
||||
info!("Grok video_status: request_id={}", args.request_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.get(&url)
|
||||
.header("Authorization", bearer)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok video_status response: http_status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
classify_status_field(&parsed)
|
||||
}
|
||||
|
||||
fn classify_status_field(parsed: &VideoStatusResponseBody) -> Result<VideoStatusSuccess, GrokError> {
|
||||
let state = match parsed.status.as_str() {
|
||||
"pending" | "queued" | "in_progress" | "processing" => VideoStatusState::Pending,
|
||||
"done" | "completed" | "succeeded" => VideoStatusState::Done,
|
||||
"failed" => {
|
||||
let (code, message) = parsed.error.as_ref()
|
||||
.map(|e| (e.code.clone(), e.message.clone()))
|
||||
.unwrap_or_else(|| ("unknown".to_string(), "video job failed without details".to_string()));
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobFailed { code, message }.into());
|
||||
}
|
||||
"expired" => {
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::VideoJobExpired.into());
|
||||
}
|
||||
other => {
|
||||
return Err(GrokGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: reqwest::StatusCode::OK,
|
||||
body: format!("unknown video status: {:?}", other),
|
||||
}.into());
|
||||
}
|
||||
};
|
||||
|
||||
let video = parsed.video.as_ref().map(|v| VideoOutputInfo {
|
||||
url: v.url.clone(),
|
||||
duration: v.duration,
|
||||
respect_moderation: v.respect_moderation,
|
||||
});
|
||||
|
||||
Ok(VideoStatusSuccess {
|
||||
state,
|
||||
progress: parsed.progress,
|
||||
model: parsed.model.clone(),
|
||||
video,
|
||||
cost_in_usd_ticks: parsed.usage.as_ref().and_then(|u| u.cost_in_usd_ticks),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
// ── Shape tests ──
|
||||
|
||||
#[test]
|
||||
fn pending_response_classifies_as_pending() {
|
||||
let json = r#"{ "status": "pending", "progress": 12, "model": "grok-imagine-video" }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let result = classify_status_field(&parsed).unwrap();
|
||||
assert_eq!(result.state, VideoStatusState::Pending);
|
||||
assert_eq!(result.progress, Some(12));
|
||||
assert_eq!(result.model.as_deref(), Some("grok-imagine-video"));
|
||||
assert!(result.video.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_response_classifies_as_done_with_video() {
|
||||
let json = r#"{
|
||||
"status": "done",
|
||||
"progress": 100,
|
||||
"model": "grok-imagine-video",
|
||||
"video": { "url": "https://imagine.x.ai/v.mp4", "duration": 5, "respect_moderation": true },
|
||||
"usage": { "cost_in_usd_ticks": 42 }
|
||||
}"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let result = classify_status_field(&parsed).unwrap();
|
||||
assert_eq!(result.state, VideoStatusState::Done);
|
||||
let video = result.video.unwrap();
|
||||
assert_eq!(video.url.as_deref(), Some("https://imagine.x.ai/v.mp4"));
|
||||
assert_eq!(video.duration, Some(5));
|
||||
assert_eq!(video.respect_moderation, Some(true));
|
||||
assert_eq!(result.cost_in_usd_ticks, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_response_returns_video_job_failed_error() {
|
||||
let json = r#"{
|
||||
"status": "failed",
|
||||
"progress": 50,
|
||||
"error": { "code": "invalid_argument", "message": "duration too long" }
|
||||
}"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let err = classify_status_field(&parsed).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, message }) => {
|
||||
assert_eq!(code, "invalid_argument");
|
||||
assert_eq!(message, "duration too long");
|
||||
}
|
||||
other => panic!("expected VideoJobFailed, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_response_without_error_object_uses_unknown() {
|
||||
let json = r#"{ "status": "failed", "progress": 0 }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let err = classify_status_field(&parsed).unwrap_err();
|
||||
match err {
|
||||
GrokError::ApiSpecific(GrokSpecificApiError::VideoJobFailed { code, .. }) => {
|
||||
assert_eq!(code, "unknown");
|
||||
}
|
||||
other => panic!("expected VideoJobFailed(unknown), got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_response_returns_expired_error() {
|
||||
let json = r#"{ "status": "expired" }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let err = classify_status_field(&parsed).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::VideoJobExpired)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_status_field_returns_generic_error() {
|
||||
let json = r#"{ "status": "magnificent" }"#;
|
||||
let parsed: VideoStatusResponseBody = serde_json::from_str(json).unwrap();
|
||||
let err = classify_status_field(&parsed).unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiGeneric(_)));
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key + an existing request_id
|
||||
async fn live_test_video_status_unknown_id_is_not_found() -> AnyhowResult<()> {
|
||||
use crate::test_utils::get_test_api_key::get_test_api_key;
|
||||
use crate::test_utils::setup_test_logging::setup_test_logging;
|
||||
setup_test_logging();
|
||||
|
||||
let api_key = get_test_api_key()?;
|
||||
// Random UUID — should yield 404.
|
||||
let result = video_status(VideoStatusArgs {
|
||||
api_key,
|
||||
request_id: "00000000-0000-0000-0000-000000000000".to_string(),
|
||||
}).await;
|
||||
|
||||
println!("Result: {:?}", result.as_ref().map(|s| (&s.state, s.progress)));
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
|
||||
"expected NotFound, got: {:?}", err);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// Base URL for the xAI Imagine API. Every request is `{XAI_API_BASE_URL}/v1/...`.
|
||||
pub(crate) const XAI_API_BASE_URL: &str = "https://api.x.ai";
|
||||
@@ -0,0 +1,19 @@
|
||||
use std::fs::read_to_string;
|
||||
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::creds::grok_api_key::GrokApiKey;
|
||||
|
||||
const GROK_API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/grok_api_key.txt";
|
||||
|
||||
/// Load a Grok (xAI) API key from the local credentials file. Whitespace is
|
||||
/// trimmed because base64-style key files often pick up a trailing newline.
|
||||
#[cfg(test)]
|
||||
pub fn get_test_api_key() -> AnyhowResult<GrokApiKey> {
|
||||
let raw = read_to_string(GROK_API_KEY_PATH)?;
|
||||
let trimmed = raw.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("Grok API key file is empty: {}", GROK_API_KEY_PATH);
|
||||
}
|
||||
Ok(GrokApiKey::new(trimmed))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) mod get_test_api_key;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod setup_test_logging;
|
||||
@@ -0,0 +1,15 @@
|
||||
use std::sync::Once;
|
||||
|
||||
static INIT: Once = Once::new();
|
||||
|
||||
/// Initialize env_logger so live-API tests print request/response details.
|
||||
/// Idempotent — safe to call from every test.
|
||||
#[cfg(test)]
|
||||
pub fn setup_test_logging() {
|
||||
INIT.call_once(|| {
|
||||
let _ = env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.is_test(true)
|
||||
.try_init();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user