mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-08 11:01:35 +00:00
api client adjustments
This commit is contained in:
1 parent
eeddfaa92b
commit
affba42619
23 files changed
+950
-73
No files matched your search
@@ -20,7 +20,9 @@ errors.workspace = true
|
||||
|
||||
# External
|
||||
log.workspace = true
|
||||
reqwest.workspace = true
|
||||
# `multipart` is enabled on top of the workspace feature set so we can use
|
||||
# `reqwest::multipart` for POST /v1/files uploads.
|
||||
reqwest = { workspace = true, features = ["multipart"] }
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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::delete_file::request_types::*;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DeleteFileArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
pub file_id: String,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeleteFileSuccess {
|
||||
pub file_id: Option<String>,
|
||||
/// Should be `true` after a successful delete.
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// DELETE https://api.x.ai/v1/files/{file_id} — delete a previously-uploaded
|
||||
/// file. After deletion the `file_id` becomes invalid and cannot be
|
||||
/// referenced in further requests.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
pub async fn delete_file(args: DeleteFileArgs) -> Result<DeleteFileSuccess, GrokError> {
|
||||
let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, args.file_id);
|
||||
|
||||
info!("Grok delete_file: file_id={}", args.file_id);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
|
||||
let bearer = format!("Bearer {}", args.api_key.api_key);
|
||||
|
||||
let response = client.delete(&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 delete_file response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: DeleteFileResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(DeleteFileSuccess {
|
||||
file_id: parsed.id,
|
||||
deleted: parsed.deleted.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_success() {
|
||||
// serde drops the `object` field on parse; only `id` and `deleted` are
|
||||
// load-bearing for our DTO.
|
||||
let json = r#"{ "id": "file_abc", "object": "file", "deleted": true }"#;
|
||||
let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.id.as_deref(), Some("file_abc"));
|
||||
assert_eq!(parsed.deleted, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes_minimal() {
|
||||
// xAI may omit some fields; only `deleted` is load-bearing.
|
||||
let json = r#"{ "deleted": true }"#;
|
||||
let parsed: DeleteFileResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.deleted, Some(true));
|
||||
assert!(parsed.id.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod delete_file;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,12 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Response shape for DELETE /v1/files/{file_id}.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub(crate) struct DeleteFileResponseBody {
|
||||
pub id: Option<String>,
|
||||
|
||||
/// xAI returns `true` on a successful delete.
|
||||
pub deleted: Option<bool>,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DownloadFileArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
pub file_id: String,
|
||||
|
||||
/// Optional `?format=` query — `"original"` returns the bytes as uploaded;
|
||||
/// `"text"` returns a textual transcription where applicable. Omit for the
|
||||
/// xAI default.
|
||||
pub format: Option<DownloadFormat>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum DownloadFormat {
|
||||
Original,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl DownloadFormat {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Original => "original",
|
||||
Self::Text => "text",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DownloadFileSuccess {
|
||||
/// Raw file content. The xAI response Content-Type is `application/octet-stream`.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// GET https://api.x.ai/v1/files/{file_id}/content — download the raw bytes
|
||||
/// of a previously-uploaded file.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/download>
|
||||
pub async fn download_file(args: DownloadFileArgs) -> Result<DownloadFileSuccess, GrokError> {
|
||||
let mut url = format!("{}/v1/files/{}/content", XAI_API_BASE_URL, args.file_id);
|
||||
if let Some(fmt) = args.format {
|
||||
url.push_str("?format=");
|
||||
url.push_str(fmt.as_str());
|
||||
}
|
||||
|
||||
info!("Grok download_file: file_id={}", args.file_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();
|
||||
|
||||
// On error, read as text so the classifier can quote it back.
|
||||
if !status.is_success() {
|
||||
let body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
classify_grok_http_error(status, Some(&body))?;
|
||||
// classify_grok_http_error always returns Err on non-success.
|
||||
unreachable!();
|
||||
}
|
||||
|
||||
let bytes = response.bytes()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok download_file response: status={}, bytes={}", status, bytes.len());
|
||||
|
||||
Ok(DownloadFileSuccess { bytes: bytes.to_vec() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn download_format_serializes() {
|
||||
assert_eq!(DownloadFormat::Original.as_str(), "original");
|
||||
assert_eq!(DownloadFormat::Text.as_str(), "text");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod download_file;
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
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::get_file_metadata::request_types::*;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GetFileMetadataArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
pub file_id: String,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileMetadata {
|
||||
pub file_id: String,
|
||||
pub bytes: Option<u64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub expires_at: Option<i64>,
|
||||
pub filename: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// GET https://api.x.ai/v1/files/{file_id} — fetch metadata for an uploaded
|
||||
/// file (no content; use `download_file` for that).
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
pub async fn get_file_metadata(args: GetFileMetadataArgs) -> Result<FileMetadata, GrokError> {
|
||||
let url = format!("{}/v1/files/{}", XAI_API_BASE_URL, args.file_id);
|
||||
|
||||
info!("Grok get_file_metadata: file_id={}", args.file_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 get_file_metadata response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: FileMetadataResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(FileMetadata {
|
||||
file_id: parsed.id,
|
||||
bytes: parsed.bytes,
|
||||
created_at: parsed.created_at,
|
||||
expires_at: parsed.expires_at,
|
||||
filename: parsed.filename,
|
||||
purpose: parsed.purpose,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{
|
||||
"id": "file_abc123",
|
||||
"object": "file",
|
||||
"bytes": 4096,
|
||||
"created_at": 1716489600,
|
||||
"expires_at": 1719081600,
|
||||
"filename": "photo.png",
|
||||
"purpose": "vision"
|
||||
}"#;
|
||||
let parsed: FileMetadataResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.id, "file_abc123");
|
||||
assert_eq!(parsed.expires_at, Some(1719081600));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key + valid file_id
|
||||
async fn live_test_unknown_file_returns_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 result = get_file_metadata(GetFileMetadataArgs {
|
||||
api_key: get_test_api_key()?,
|
||||
file_id: "file_doesnotexist000000".to_string(),
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::NotFound)),
|
||||
"expected NotFound, got: {:?}", err);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod get_file_metadata;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Response shape for GET /v1/files/{file_id}. Same envelope as the upload
|
||||
/// response (xAI returns identical file objects from both endpoints).
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub(crate) struct FileMetadataResponseBody {
|
||||
pub id: String,
|
||||
pub bytes: Option<u64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub expires_at: Option<i64>,
|
||||
pub filename: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
@@ -7,10 +7,12 @@ 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::image_types::image_aspect_ratio::ImageAspectRatio;
|
||||
use crate::requests::image_types::image_model::ImageModel;
|
||||
use crate::requests::image_types::image_resolution::ImageResolution;
|
||||
use crate::requests::image_types::image_response_format::ImageResponseFormat;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
const DEFAULT_MODEL: &str = "grok-imagine-image-quality";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -24,38 +26,43 @@ pub struct ImageEditArgs {
|
||||
/// 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>,
|
||||
/// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`] when `None`.
|
||||
pub model: Option<ImageModel>,
|
||||
|
||||
/// Number of edited images to render. Server default is 1 when `None`.
|
||||
pub number_images: Option<u32>,
|
||||
|
||||
/// Aspect ratio. See [`ImageAspectRatio`]. When `None`, xAI defaults to
|
||||
/// matching the first input image.
|
||||
pub aspect_ratio: Option<ImageAspectRatio>,
|
||||
|
||||
/// Output resolution tier. See [`ImageResolution`].
|
||||
pub resolution: Option<ImageResolution>,
|
||||
|
||||
/// `Url` (default) or `B64Json`.
|
||||
pub response_format: Option<ImageResponseFormat>,
|
||||
|
||||
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).
|
||||
/// Source image — pick by URL or by `file_id` (from a prior upload).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ImageSource {
|
||||
/// Either a public HTTPS URL or a `data:` URI containing base64-encoded
|
||||
/// image bytes.
|
||||
Url(String),
|
||||
|
||||
/// xAI file identifier (`file_...`) obtained from a successful upload via
|
||||
/// [`crate::requests::upload_file::upload_file::upload_file`]. The file
|
||||
/// must still exist at request time (it has not been deleted and any
|
||||
/// `expires_after` has not lapsed).
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
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)]
|
||||
@@ -86,11 +93,13 @@ pub async fn image_edit(args: ImageEditArgs) -> Result<ImageEditSuccess, GrokErr
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/images/edits", XAI_API_BASE_URL);
|
||||
let model = args.model.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||
let model = args.model.unwrap_or(ImageModel::GrokImagineImageQuality);
|
||||
|
||||
info!(
|
||||
"Grok image_edit: model={}, sources={}, aspect_ratio={:?}, resolution={:?}",
|
||||
model, args.source_images.len(), args.aspect_ratio, args.resolution
|
||||
model.as_str(), args.source_images.len(),
|
||||
args.aspect_ratio.map(|a| a.as_str()),
|
||||
args.resolution.map(|r| r.as_str()),
|
||||
);
|
||||
|
||||
// Single source → `image`. Multiple sources → `images`.
|
||||
@@ -104,10 +113,10 @@ pub async fn image_edit(args: ImageEditArgs) -> Result<ImageEditSuccess, GrokErr
|
||||
prompt: args.prompt,
|
||||
image,
|
||||
images,
|
||||
model: Some(model),
|
||||
n: args.n,
|
||||
aspect_ratio: args.aspect_ratio,
|
||||
resolution: args.resolution,
|
||||
model: Some(model.as_str().to_string()),
|
||||
n: args.number_images,
|
||||
aspect_ratio: args.aspect_ratio.map(|a| a.as_str().to_string()),
|
||||
resolution: args.resolution.map(|r| r.as_str().to_string()),
|
||||
response_format: args.response_format.map(|f| f.as_str().to_string()),
|
||||
user: args.user,
|
||||
};
|
||||
@@ -211,7 +220,7 @@ mod tests {
|
||||
prompt: "edit".to_string(),
|
||||
source_images: vec![],
|
||||
model: None,
|
||||
n: None,
|
||||
number_images: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
@@ -261,7 +270,7 @@ mod tests {
|
||||
"https://docs.x.ai/assets/api-examples/images/style-realistic.png".to_string()
|
||||
)],
|
||||
model: None,
|
||||
n: None,
|
||||
number_images: None,
|
||||
aspect_ratio: None,
|
||||
resolution: None,
|
||||
response_format: None,
|
||||
|
||||
+44
-40
@@ -6,10 +6,12 @@ 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::image_types::image_aspect_ratio::ImageAspectRatio;
|
||||
use crate::requests::image_types::image_model::ImageModel;
|
||||
use crate::requests::image_types::image_resolution::ImageResolution;
|
||||
use crate::requests::image_types::image_response_format::ImageResponseFormat;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
const DEFAULT_MODEL: &str = "grok-imagine-image-quality";
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -19,40 +21,29 @@ pub struct ImageGenerationArgs {
|
||||
/// Text prompt describing the image. Required.
|
||||
pub prompt: String,
|
||||
|
||||
/// Model identifier. Defaults to `grok-imagine-image-quality` if `None`.
|
||||
pub model: Option<String>,
|
||||
/// Model identifier. Defaults to [`ImageModel::GrokImagineImageQuality`]
|
||||
/// when `None`. Use [`ImageModel::Custom`] for identifiers not yet listed
|
||||
/// in the enum.
|
||||
pub model: Option<ImageModel>,
|
||||
|
||||
/// Number of images to generate. Server default (typically 1) if `None`.
|
||||
pub n: Option<u32>,
|
||||
/// Number of images to render in this request. xAI's docs don't state a
|
||||
/// hard maximum; server default is 1 when `None`.
|
||||
pub number_images: Option<u32>,
|
||||
|
||||
/// Aspect ratio (e.g. "1:1", "16:9", "auto"). Server default if `None`.
|
||||
pub aspect_ratio: Option<String>,
|
||||
/// Aspect ratio. See [`ImageAspectRatio`] for the closed set of accepted
|
||||
/// values. Server default when `None`.
|
||||
pub aspect_ratio: Option<ImageAspectRatio>,
|
||||
|
||||
/// Resolution (`"1k"` or `"2k"`). Server default if `None`.
|
||||
pub resolution: Option<String>,
|
||||
/// Output resolution tier. See [`ImageResolution`]. Server default when `None`.
|
||||
pub resolution: Option<ImageResolution>,
|
||||
|
||||
/// `"url"` (default) or `"b64_json"`.
|
||||
/// Url (default) or b64 inline. See [`ImageResponseFormat`].
|
||||
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)]
|
||||
@@ -83,19 +74,21 @@ pub struct GeneratedImage {
|
||||
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());
|
||||
let model = args.model.unwrap_or(ImageModel::GrokImagineImageQuality);
|
||||
|
||||
info!(
|
||||
"Grok image_generation: model={}, n={:?}, aspect_ratio={:?}, resolution={:?}",
|
||||
model, args.n, args.aspect_ratio, args.resolution
|
||||
"Grok image_generation: model={}, number_images={:?}, aspect_ratio={:?}, resolution={:?}",
|
||||
model.as_str(), args.number_images,
|
||||
args.aspect_ratio.map(|a| a.as_str()),
|
||||
args.resolution.map(|r| r.as_str()),
|
||||
);
|
||||
|
||||
let request_body = ImageGenerationRequestBody {
|
||||
prompt: args.prompt,
|
||||
model: Some(model),
|
||||
n: args.n,
|
||||
aspect_ratio: args.aspect_ratio,
|
||||
resolution: args.resolution,
|
||||
model: Some(model.as_str().to_string()),
|
||||
n: args.number_images,
|
||||
aspect_ratio: args.aspect_ratio.map(|a| a.as_str().to_string()),
|
||||
resolution: args.resolution.map(|r| r.as_str().to_string()),
|
||||
response_format: args.response_format.map(|f| f.as_str().to_string()),
|
||||
user: args.user,
|
||||
};
|
||||
@@ -207,10 +200,21 @@ mod tests {
|
||||
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");
|
||||
// (Enum-specific round-trips live in their own modules under
|
||||
// `requests::image_types::*`; here we just verify the public args wire up.)
|
||||
|
||||
#[tokio::test]
|
||||
async fn args_with_enums_serializes_correct_strings() {
|
||||
// We can't reach the inner serde body directly without calling the API,
|
||||
// but we can verify the conversion functions yield exactly the docs values.
|
||||
let m = ImageModel::GrokImagineImageQuality;
|
||||
let a = ImageAspectRatio::Landscape16x9;
|
||||
let r = ImageResolution::OneK;
|
||||
let f = ImageResponseFormat::B64Json;
|
||||
assert_eq!(m.as_str(), "grok-imagine-image-quality");
|
||||
assert_eq!(a.as_str(), "16:9");
|
||||
assert_eq!(r.as_str(), "1k");
|
||||
assert_eq!(f.as_str(), "b64_json");
|
||||
}
|
||||
|
||||
// ── Live API tests (ignored — incur cost) ──
|
||||
@@ -227,9 +231,9 @@ mod tests {
|
||||
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()),
|
||||
number_images: None,
|
||||
aspect_ratio: Some(ImageAspectRatio::Landscape16x9),
|
||||
resolution: Some(ImageResolution::OneK),
|
||||
response_format: None,
|
||||
user: None,
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/// Aspect ratios accepted by the xAI image endpoints.
|
||||
///
|
||||
/// The wire format is `"<width>:<height>"` — e.g. `Square` serialises as
|
||||
/// `"1:1"`, `Cinematic20x9` as `"20:9"`. `Auto` defers to xAI to pick.
|
||||
///
|
||||
/// xAI documents this closed set; see
|
||||
/// <https://docs.x.ai/developers/rest-api-reference/inference/images>.
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageAspectRatio {
|
||||
/// 1:1 square.
|
||||
Square,
|
||||
|
||||
/// 3:4 portrait (slight tall).
|
||||
Portrait3x4,
|
||||
|
||||
/// 4:3 landscape (slight wide).
|
||||
Landscape4x3,
|
||||
|
||||
/// 9:16 portrait (phone/vertical-video).
|
||||
Portrait9x16,
|
||||
|
||||
/// 16:9 landscape (widescreen / YouTube).
|
||||
Landscape16x9,
|
||||
|
||||
/// 2:3 portrait (photo).
|
||||
Portrait2x3,
|
||||
|
||||
/// 3:2 landscape (photo).
|
||||
Landscape3x2,
|
||||
|
||||
/// 9:19.5 portrait (modern phone screens).
|
||||
Portrait9x19_5,
|
||||
|
||||
/// 19.5:9 landscape (modern phone screens, rotated).
|
||||
Landscape19_5x9,
|
||||
|
||||
/// 9:20 portrait (taller phone).
|
||||
Portrait9x20,
|
||||
|
||||
/// 20:9 landscape (taller phone, rotated).
|
||||
Landscape20x9,
|
||||
|
||||
/// 1:2 ultra-portrait.
|
||||
Portrait1x2,
|
||||
|
||||
/// 2:1 ultra-landscape.
|
||||
Landscape2x1,
|
||||
|
||||
/// Let xAI pick the aspect ratio (typically matches the source image for edits).
|
||||
Auto,
|
||||
}
|
||||
|
||||
impl ImageAspectRatio {
|
||||
/// Wire representation — the exact string xAI expects in the
|
||||
/// `"aspect_ratio"` field.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Square => "1:1",
|
||||
Self::Portrait3x4 => "3:4",
|
||||
Self::Landscape4x3 => "4:3",
|
||||
Self::Portrait9x16 => "9:16",
|
||||
Self::Landscape16x9 => "16:9",
|
||||
Self::Portrait2x3 => "2:3",
|
||||
Self::Landscape3x2 => "3:2",
|
||||
Self::Portrait9x19_5 => "9:19.5",
|
||||
Self::Landscape19_5x9 => "19.5:9",
|
||||
Self::Portrait9x20 => "9:20",
|
||||
Self::Landscape20x9 => "20:9",
|
||||
Self::Portrait1x2 => "1:2",
|
||||
Self::Landscape2x1 => "2:1",
|
||||
Self::Auto => "auto",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_variant_maps_to_a_distinct_string() {
|
||||
let all = [
|
||||
ImageAspectRatio::Square,
|
||||
ImageAspectRatio::Portrait3x4,
|
||||
ImageAspectRatio::Landscape4x3,
|
||||
ImageAspectRatio::Portrait9x16,
|
||||
ImageAspectRatio::Landscape16x9,
|
||||
ImageAspectRatio::Portrait2x3,
|
||||
ImageAspectRatio::Landscape3x2,
|
||||
ImageAspectRatio::Portrait9x19_5,
|
||||
ImageAspectRatio::Landscape19_5x9,
|
||||
ImageAspectRatio::Portrait9x20,
|
||||
ImageAspectRatio::Landscape20x9,
|
||||
ImageAspectRatio::Portrait1x2,
|
||||
ImageAspectRatio::Landscape2x1,
|
||||
ImageAspectRatio::Auto,
|
||||
];
|
||||
let mut strs: Vec<&str> = all.iter().map(|v| v.as_str()).collect();
|
||||
strs.sort();
|
||||
strs.dedup();
|
||||
assert_eq!(strs.len(), 14, "expected 14 distinct wire values, got {:?}", strs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(ImageAspectRatio::Square.as_str(), "1:1");
|
||||
assert_eq!(ImageAspectRatio::Landscape16x9.as_str(), "16:9");
|
||||
assert_eq!(ImageAspectRatio::Portrait9x19_5.as_str(), "9:19.5");
|
||||
assert_eq!(ImageAspectRatio::Landscape19_5x9.as_str(), "19.5:9");
|
||||
assert_eq!(ImageAspectRatio::Auto.as_str(), "auto");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/// Models accepted by the xAI image endpoints (`/v1/images/generations` and
|
||||
/// `/v1/images/edits`).
|
||||
///
|
||||
/// xAI may add or deprecate models faster than this crate ships releases, so
|
||||
/// the [`ImageModel::Custom`] escape hatch lets callers pass an arbitrary
|
||||
/// identifier without waiting for a code change.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/generation>
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/editing>
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageModel {
|
||||
/// `grok-imagine-image-quality` — the current default high-quality model.
|
||||
GrokImagineImageQuality,
|
||||
|
||||
/// Escape hatch for model identifiers not yet enumerated here (e.g. a
|
||||
/// newly-released model or a deprecated one xAI hasn't yet removed).
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl ImageModel {
|
||||
/// Wire representation — the exact string xAI expects in the `"model"` field.
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::GrokImagineImageQuality => "grok-imagine-image-quality",
|
||||
Self::Custom(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_model_serializes() {
|
||||
assert_eq!(ImageModel::GrokImagineImageQuality.as_str(), "grok-imagine-image-quality");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_model_passes_through() {
|
||||
let m = ImageModel::Custom("grok-imagine-image-future".to_string());
|
||||
assert_eq!(m.as_str(), "grok-imagine-image-future");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/// Output resolution for xAI image endpoints.
|
||||
///
|
||||
/// xAI's actual pixel counts depend on the chosen `aspect_ratio` — the `1k`
|
||||
/// and `2k` labels denote the *target* size class (roughly 1024-px and
|
||||
/// 2048-px on the longest edge). When omitted, xAI falls back to its server
|
||||
/// default (currently 1k as of these docs).
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/generation>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/inference/images>
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ImageResolution {
|
||||
/// `"1k"` — ~1024-px class (faster, cheaper).
|
||||
OneK,
|
||||
/// `"2k"` — ~2048-px class (higher fidelity, slower, more expensive).
|
||||
TwoK,
|
||||
}
|
||||
|
||||
impl ImageResolution {
|
||||
/// Wire representation — the exact string xAI expects in the `"resolution"` field.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::OneK => "1k",
|
||||
Self::TwoK => "2k",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(ImageResolution::OneK.as_str(), "1k");
|
||||
assert_eq!(ImageResolution::TwoK.as_str(), "2k");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// Wire format for returned images.
|
||||
///
|
||||
/// - `Url` (default) — xAI returns a temporary CDN URL in `data[].url`.
|
||||
/// - `B64Json` — xAI returns the raw image bytes inlined as base64 in
|
||||
/// `data[].b64_json`. Use this when you can't / don't want to make a
|
||||
/// follow-up HTTP request to fetch the image.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/model-capabilities/images/generation>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/inference/images>
|
||||
#[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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_docs_strings() {
|
||||
assert_eq!(ImageResponseFormat::Url.as_str(), "url");
|
||||
assert_eq!(ImageResponseFormat::B64Json.as_str(), "b64_json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Shared image-endpoint enums (model, aspect ratio, resolution, response format).
|
||||
//!
|
||||
//! These are reused by both [`crate::requests::image_generation`] and
|
||||
//! [`crate::requests::image_edit`] so callers can pass type-safe values
|
||||
//! instead of memorising the allowed strings.
|
||||
|
||||
pub mod image_aspect_ratio;
|
||||
pub mod image_model;
|
||||
pub mod image_resolution;
|
||||
pub mod image_response_format;
|
||||
@@ -1,5 +1,10 @@
|
||||
pub mod delete_file;
|
||||
pub mod download_file;
|
||||
pub mod get_file_metadata;
|
||||
pub mod image_edit;
|
||||
pub mod image_generation;
|
||||
pub mod image_types;
|
||||
pub mod upload_file;
|
||||
pub mod video_edit;
|
||||
pub mod video_extension;
|
||||
pub mod video_generation;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod upload_file;
|
||||
pub(crate) mod request_types;
|
||||
@@ -0,0 +1,14 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
/// Response shape for POST /v1/files. Matches OpenAI-style file objects.
|
||||
///
|
||||
/// Docs: <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub(crate) struct UploadFileResponseBody {
|
||||
pub id: String,
|
||||
pub bytes: Option<u64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub expires_at: Option<i64>,
|
||||
pub filename: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use log::info;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
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::upload_file::request_types::*;
|
||||
use crate::requests::xai_host::XAI_API_BASE_URL;
|
||||
|
||||
const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024;
|
||||
|
||||
// ── Public args ──
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UploadFileArgs {
|
||||
pub api_key: GrokApiKey,
|
||||
|
||||
/// Raw bytes of the file. xAI caps uploads at 50 MB.
|
||||
pub file_bytes: Vec<u8>,
|
||||
|
||||
/// Original filename. xAI extracts this from the multipart
|
||||
/// `Content-Disposition: filename=...` header and surfaces it back in the
|
||||
/// response.
|
||||
pub filename: String,
|
||||
|
||||
/// Optional MIME type. If `None`, reqwest uses `application/octet-stream`.
|
||||
pub maybe_content_type: Option<String>,
|
||||
|
||||
/// Time-to-live in seconds (3600 – 2_592_000, i.e. 1 hour to 30 days). If
|
||||
/// `None`, the file persists indefinitely until explicitly deleted.
|
||||
pub expires_after_seconds: Option<u32>,
|
||||
|
||||
/// OpenAI-compatibility label. xAI accepts but doesn't enforce this — set
|
||||
/// it if porting code that already uses OpenAI's Files API.
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
|
||||
// ── Public response ──
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadFileSuccess {
|
||||
/// The xAI file identifier. Use this in the `FileId(_)` variant of
|
||||
/// [`crate::requests::image_edit::image_edit::ImageSource`],
|
||||
/// [`crate::requests::video_generation::video_generation::VideoImageSource`],
|
||||
/// [`crate::requests::video_edit::video_edit::VideoSource`], or
|
||||
/// [`crate::requests::video_extension::video_extension::VideoExtensionSource`].
|
||||
pub file_id: String,
|
||||
|
||||
pub bytes: Option<u64>,
|
||||
pub created_at: Option<i64>,
|
||||
pub expires_at: Option<i64>,
|
||||
pub filename: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
}
|
||||
|
||||
// ── Implementation ──
|
||||
|
||||
/// POST https://api.x.ai/v1/files — upload a file and receive a `file_id`
|
||||
/// you can reference in subsequent image/video edit, generation, or
|
||||
/// extension calls.
|
||||
///
|
||||
/// xAI documentation:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
pub async fn upload_file(args: UploadFileArgs) -> Result<UploadFileSuccess, GrokError> {
|
||||
if args.file_bytes.is_empty() {
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
|
||||
"upload_file: file_bytes is empty".to_string(),
|
||||
).into());
|
||||
}
|
||||
if args.file_bytes.len() as u64 > MAX_FILE_BYTES {
|
||||
return Err(crate::error::grok_specific_api_error::GrokSpecificApiError::BadRequest(
|
||||
format!("upload_file: file_bytes is {} bytes, exceeds xAI's 50 MB limit", args.file_bytes.len()),
|
||||
).into());
|
||||
}
|
||||
|
||||
let url = format!("{}/v1/files", XAI_API_BASE_URL);
|
||||
|
||||
info!(
|
||||
"Grok upload_file: filename={}, bytes={}, expires_after={:?}, purpose={:?}",
|
||||
args.filename, args.file_bytes.len(), args.expires_after_seconds, args.purpose
|
||||
);
|
||||
|
||||
let mut file_part = Part::bytes(args.file_bytes).file_name(args.filename.clone());
|
||||
if let Some(ct) = args.maybe_content_type.as_deref() {
|
||||
file_part = file_part.mime_str(ct)
|
||||
.map_err(GrokClientError::ReqwestClientError)?;
|
||||
}
|
||||
|
||||
let mut form = Form::new().part("file", file_part);
|
||||
if let Some(secs) = args.expires_after_seconds {
|
||||
form = form.text("expires_after", secs.to_string());
|
||||
}
|
||||
if let Some(purpose) = args.purpose {
|
||||
form = form.text("purpose", purpose);
|
||||
}
|
||||
|
||||
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)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
let status = response.status();
|
||||
let response_body = response.text()
|
||||
.await
|
||||
.map_err(GrokGenericApiError::ReqwestError)?;
|
||||
|
||||
info!("Grok upload_file response: status={}", status);
|
||||
|
||||
classify_grok_http_error(status, Some(&response_body))?;
|
||||
|
||||
let parsed: UploadFileResponseBody = serde_json::from_str(&response_body)
|
||||
.map_err(|err| GrokGenericApiError::SerdeResponseParseErrorWithBody(err, response_body.clone()))?;
|
||||
|
||||
Ok(UploadFileSuccess {
|
||||
file_id: parsed.id,
|
||||
bytes: parsed.bytes,
|
||||
created_at: parsed.created_at,
|
||||
expires_at: parsed.expires_at,
|
||||
filename: parsed.filename,
|
||||
purpose: parsed.purpose,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use errors::AnyhowResult;
|
||||
|
||||
use crate::error::grok_specific_api_error::GrokSpecificApiError;
|
||||
|
||||
// ── Client-side validation ──
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_bytes_returns_bad_request() {
|
||||
let result = upload_file(UploadFileArgs {
|
||||
api_key: GrokApiKey::new("dummy".to_string()),
|
||||
file_bytes: vec![],
|
||||
filename: "x.png".to_string(),
|
||||
maybe_content_type: None,
|
||||
expires_after_seconds: None,
|
||||
purpose: None,
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn over_size_limit_returns_bad_request() {
|
||||
let oversized: Vec<u8> = vec![0u8; (MAX_FILE_BYTES + 1) as usize];
|
||||
let result = upload_file(UploadFileArgs {
|
||||
api_key: GrokApiKey::new("dummy".to_string()),
|
||||
file_bytes: oversized,
|
||||
filename: "x.png".to_string(),
|
||||
maybe_content_type: None,
|
||||
expires_after_seconds: None,
|
||||
purpose: None,
|
||||
}).await;
|
||||
let err = result.unwrap_err();
|
||||
assert!(matches!(err, GrokError::ApiSpecific(GrokSpecificApiError::BadRequest(_))));
|
||||
}
|
||||
|
||||
// ── Response parsing ──
|
||||
|
||||
#[test]
|
||||
fn response_body_deserializes() {
|
||||
let json = r#"{
|
||||
"id": "file_abc123",
|
||||
"object": "file",
|
||||
"bytes": 1024,
|
||||
"created_at": 1716489600,
|
||||
"expires_at": null,
|
||||
"filename": "photo.png",
|
||||
"purpose": "vision"
|
||||
}"#;
|
||||
let parsed: UploadFileResponseBody = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(parsed.id, "file_abc123");
|
||||
assert_eq!(parsed.bytes, Some(1024));
|
||||
assert_eq!(parsed.filename.as_deref(), Some("photo.png"));
|
||||
}
|
||||
|
||||
// ── Live API tests ──
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // manually test — requires real API key
|
||||
async fn live_test_upload_small_png() -> 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();
|
||||
|
||||
// 1x1 transparent PNG
|
||||
let png_bytes: Vec<u8> = vec![
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
|
||||
0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41,
|
||||
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
|
||||
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
|
||||
0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
let result = upload_file(UploadFileArgs {
|
||||
api_key: get_test_api_key()?,
|
||||
file_bytes: png_bytes,
|
||||
filename: "test_1x1.png".to_string(),
|
||||
maybe_content_type: Some("image/png".to_string()),
|
||||
expires_after_seconds: Some(3600),
|
||||
purpose: None,
|
||||
}).await.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
println!("Uploaded file_id={}, bytes={:?}", result.file_id, result.bytes);
|
||||
assert!(!result.file_id.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,20 @@ pub struct VideoEditArgs {
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// Source video to edit. Pick by public URL or by a previously-uploaded
|
||||
/// xAI file_id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoSource {
|
||||
/// Public HTTPS URL pointing to the source video. xAI fetches the bytes
|
||||
/// on its end.
|
||||
Url(String),
|
||||
|
||||
/// xAI file identifier (`file_...`) obtained from a successful upload via
|
||||
/// [`crate::requests::upload_file::upload_file::upload_file`].
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,19 @@ pub struct VideoExtensionArgs {
|
||||
pub upload_url: Option<String>,
|
||||
}
|
||||
|
||||
/// Source video to extend. Pick by public URL or by a previously-uploaded
|
||||
/// xAI file_id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoExtensionSource {
|
||||
/// Public HTTPS URL pointing to the source video.
|
||||
Url(String),
|
||||
|
||||
/// xAI file identifier (`file_...`) obtained from a successful upload via
|
||||
/// [`crate::requests::upload_file::upload_file::upload_file`].
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,21 @@ pub struct VideoGenerationArgs {
|
||||
pub user: Option<String>,
|
||||
}
|
||||
|
||||
/// Source image for `image` (image-to-video) or `reference_images`
|
||||
/// (reference-to-video).
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum VideoImageSource {
|
||||
/// Either a public HTTPS URL or a `data:` URI containing base64-encoded
|
||||
/// image bytes.
|
||||
Url(String),
|
||||
|
||||
/// xAI file identifier (`file_...`) obtained from a successful upload via
|
||||
/// [`crate::requests::upload_file::upload_file::upload_file`]. The file
|
||||
/// must still exist at request time.
|
||||
///
|
||||
/// Docs:
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/upload>
|
||||
/// - <https://docs.x.ai/developers/rest-api-reference/files/manage>
|
||||
FileId(String),
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user