From 996bd3448c3ced590e1447b517de457d36c8fe88 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 8 May 2026 12:21:39 -0400 Subject: [PATCH] beeble client --- Cargo.lock | 14 + Cargo.toml | 2 + crates/api_clients/beeble_client/CLAUDE.md | 24 ++ crates/api_clients/beeble_client/Cargo.toml | 29 ++ .../beeble_client/src/creds/beeble_api_key.rs | 20 ++ .../beeble_client/src/creds/mod.rs | 1 + .../src/error/beeble_client_error.rs | 26 ++ .../beeble_client/src/error/beeble_error.rs | 41 +++ .../src/error/beeble_generic_api_error.rs | 34 ++ .../src/error/beeble_specific_api_error.rs | 35 +++ .../beeble_client/src/error/mod.rs | 4 + crates/api_clients/beeble_client/src/lib.rs | 7 + .../create_upload_url/create_upload_url.rs | 126 ++++++++ .../src/requests/create_upload_url/mod.rs | 2 + .../create_upload_url/request_types.rs | 13 + .../requests/get_job_status/get_job_status.rs | 90 ++++++ .../src/requests/get_job_status/mod.rs | 1 + .../beeble_client/src/requests/mod.rs | 3 + .../src/requests/start_generation/mod.rs | 2 + .../start_generation/request_types.rs | 55 ++++ .../start_generation/start_generation.rs | 292 ++++++++++++++++++ .../src/test_utils/get_test_api_key.rs | 11 + .../beeble_client/src/test_utils/mod.rs | 1 + 23 files changed, 833 insertions(+) create mode 100644 crates/api_clients/beeble_client/CLAUDE.md create mode 100644 crates/api_clients/beeble_client/Cargo.toml create mode 100644 crates/api_clients/beeble_client/src/creds/beeble_api_key.rs create mode 100644 crates/api_clients/beeble_client/src/creds/mod.rs create mode 100644 crates/api_clients/beeble_client/src/error/beeble_client_error.rs create mode 100644 crates/api_clients/beeble_client/src/error/beeble_error.rs create mode 100644 crates/api_clients/beeble_client/src/error/beeble_generic_api_error.rs create mode 100644 crates/api_clients/beeble_client/src/error/beeble_specific_api_error.rs create mode 100644 crates/api_clients/beeble_client/src/error/mod.rs create mode 100644 crates/api_clients/beeble_client/src/lib.rs create mode 100644 crates/api_clients/beeble_client/src/requests/create_upload_url/create_upload_url.rs create mode 100644 crates/api_clients/beeble_client/src/requests/create_upload_url/mod.rs create mode 100644 crates/api_clients/beeble_client/src/requests/create_upload_url/request_types.rs create mode 100644 crates/api_clients/beeble_client/src/requests/get_job_status/get_job_status.rs create mode 100644 crates/api_clients/beeble_client/src/requests/get_job_status/mod.rs create mode 100644 crates/api_clients/beeble_client/src/requests/mod.rs create mode 100644 crates/api_clients/beeble_client/src/requests/start_generation/mod.rs create mode 100644 crates/api_clients/beeble_client/src/requests/start_generation/request_types.rs create mode 100644 crates/api_clients/beeble_client/src/requests/start_generation/start_generation.rs create mode 100644 crates/api_clients/beeble_client/src/test_utils/get_test_api_key.rs create mode 100644 crates/api_clients/beeble_client/src/test_utils/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 8c83e1ec82..88b0e544e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1404,6 +1404,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "beeble_client" +version = "0.0.1" +dependencies = [ + "errors", + "log", + "reqwest 0.12.23", + "serde", + "serde_derive", + "serde_json", + "tokio", + "workspace-build-acceleration", +] + [[package]] name = "bigdecimal" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 2c5c28481c..19a56407ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/api_clients/grok_client", "crates/api_clients/midjourney_client", "crates/api_clients/muapi_client", + "crates/api_clients/beeble_client", "crates/api_clients/openai_sora_client", "crates/api_clients/openart_client", "crates/api_clients/rootly_client", @@ -244,6 +245,7 @@ fal_client = { path = "crates/api_clients/fal_client" } 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_client = { path = "crates/api_clients/grok_client" } rootly_client = { path = "crates/api_clients/rootly_client" } rootly_config = { path = "crates/schema/config/rootly_config" } diff --git a/crates/api_clients/beeble_client/CLAUDE.md b/crates/api_clients/beeble_client/CLAUDE.md new file mode 100644 index 0000000000..cd77e5c204 --- /dev/null +++ b/crates/api_clients/beeble_client/CLAUDE.md @@ -0,0 +1,24 @@ +# beeble_client + +HTTP client for the Beeble SwitchX compositing API. + +## API Reference + +- Docs: https://developer.beeble.ai/docs +- OpenAPI: https://api.beeble.ai/developer-api-docs/openapi.json +- Auth: `x-api-key` header + +## Endpoints + +- `create_upload_url` — POST /v1/uploads — get a presigned PUT URL for uploading media +- `start_generation` — POST /v1/switchx/generations — start an image or video compositing job +- `get_job_status` — GET /v1/switchx/generations/{job_id} — poll job status until completed/failed + +## Testing + +```bash +cargo test -p beeble_client # run non-ignored tests (API shape only) +cargo test -p beeble_client -- --ignored # run live API tests (requires key, incurs costs) +``` + +Live tests require: `/Users/bt/Artcraft/credentials/beeble_api_key.txt` diff --git a/crates/api_clients/beeble_client/Cargo.toml b/crates/api_clients/beeble_client/Cargo.toml new file mode 100644 index 0000000000..52a8c3f4bf --- /dev/null +++ b/crates/api_clients/beeble_client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "beeble_client" +edition = "2024" +version = "0.0.1" +publish = false + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[lib] +name = "beeble_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] +tokio = { version = "1", features = ["full"] } diff --git a/crates/api_clients/beeble_client/src/creds/beeble_api_key.rs b/crates/api_clients/beeble_client/src/creds/beeble_api_key.rs new file mode 100644 index 0000000000..d60fdb32b6 --- /dev/null +++ b/crates/api_clients/beeble_client/src/creds/beeble_api_key.rs @@ -0,0 +1,20 @@ +use std::fmt; + +/// Holds the API key for authenticating with the Beeble API. +/// Sent as the `x-api-key` header. +#[derive(Clone)] +pub struct BeebleApiKey { + pub(crate) api_key: String, +} + +impl BeebleApiKey { + pub fn new(api_key: String) -> Self { + Self { api_key } + } +} + +impl fmt::Debug for BeebleApiKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "BeebleApiKey(***)") + } +} diff --git a/crates/api_clients/beeble_client/src/creds/mod.rs b/crates/api_clients/beeble_client/src/creds/mod.rs new file mode 100644 index 0000000000..a4d53a70b5 --- /dev/null +++ b/crates/api_clients/beeble_client/src/creds/mod.rs @@ -0,0 +1 @@ +pub mod beeble_api_key; diff --git a/crates/api_clients/beeble_client/src/error/beeble_client_error.rs b/crates/api_clients/beeble_client/src/error/beeble_client_error.rs new file mode 100644 index 0000000000..58fb17de62 --- /dev/null +++ b/crates/api_clients/beeble_client/src/error/beeble_client_error.rs @@ -0,0 +1,26 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum BeebleClientError { + /// No API key is present. + NoApiKeyPresent, + + /// An error was encountered in building the reqwest client. + ReqwestClientError(reqwest::Error), + + /// A serialization error with the request. + SerializationError(serde_json::Error), +} + +impl Error for BeebleClientError {} + +impl Display for BeebleClientError { + 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: {}", err), + Self::SerializationError(err) => write!(f, "Serialization error: {}", err), + } + } +} diff --git a/crates/api_clients/beeble_client/src/error/beeble_error.rs b/crates/api_clients/beeble_client/src/error/beeble_error.rs new file mode 100644 index 0000000000..dddca7ab04 --- /dev/null +++ b/crates/api_clients/beeble_client/src/error/beeble_error.rs @@ -0,0 +1,41 @@ +use crate::error::beeble_client_error::BeebleClientError; +use crate::error::beeble_generic_api_error::BeebleGenericApiError; +use crate::error::beeble_specific_api_error::BeebleSpecificApiError; +use std::error::Error; + +#[derive(Debug)] +pub enum BeebleError { + Client(BeebleClientError), + ApiSpecific(BeebleSpecificApiError), + ApiGeneric(BeebleGenericApiError), +} + +impl Error for BeebleError {} + +impl std::fmt::Display for BeebleError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "BeebleClientError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "BeebleSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "BeebleGenericApiError: {:?}", e), + } + } +} + +impl From for BeebleError { + fn from(error: BeebleClientError) -> Self { + Self::Client(error) + } +} + +impl From for BeebleError { + fn from(error: BeebleSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for BeebleError { + fn from(error: BeebleGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} diff --git a/crates/api_clients/beeble_client/src/error/beeble_generic_api_error.rs b/crates/api_clients/beeble_client/src/error/beeble_generic_api_error.rs new file mode 100644 index 0000000000..7184ec916d --- /dev/null +++ b/crates/api_clients/beeble_client/src/error/beeble_generic_api_error.rs @@ -0,0 +1,34 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Catch-all for API errors that don't fit a specific variant. +#[derive(Debug)] +pub enum BeebleGenericApiError { + /// An HTTP transport error from reqwest. + ReqwestError(reqwest::Error), + + /// Failed to parse the response body as JSON. + SerdeResponseParseError(serde_json::Error, String), + + /// The API returned a non-success status code. + UncategorizedBadResponseWithStatusAndBody { + status_code: reqwest::StatusCode, + body: String, + }, +} + +impl Error for BeebleGenericApiError {} + +impl Display for BeebleGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ReqwestError(err) => write!(f, "Beeble reqwest error: {}", err), + Self::SerdeResponseParseError(err, body) => { + write!(f, "Beeble response parse error: {} (body: {})", err, body) + } + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => { + write!(f, "Beeble API error: status={}, body={}", status_code, body) + } + } + } +} diff --git a/crates/api_clients/beeble_client/src/error/beeble_specific_api_error.rs b/crates/api_clients/beeble_client/src/error/beeble_specific_api_error.rs new file mode 100644 index 0000000000..aa422092f7 --- /dev/null +++ b/crates/api_clients/beeble_client/src/error/beeble_specific_api_error.rs @@ -0,0 +1,35 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Well-known API errors from the Beeble service. +#[derive(Debug)] +pub enum BeebleSpecificApiError { + /// 401 Unauthorized — invalid or missing API key. + Unauthorized, + + /// 402 Payment Required — insufficient credits. + InsufficientCredits, + + /// 409 Conflict — idempotency key reused with different parameters. + IdempotencyConflict, + + /// 429 Too Many Requests — rate limit exceeded. + RateLimited, + + /// The generation job failed with a reported error message. + GenerationFailed(String), +} + +impl Error for BeebleSpecificApiError {} + +impl Display for BeebleSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unauthorized => write!(f, "Beeble API: Unauthorized (invalid or missing API key)"), + Self::InsufficientCredits => write!(f, "Beeble API: Insufficient credits"), + Self::IdempotencyConflict => write!(f, "Beeble API: Idempotency conflict"), + Self::RateLimited => write!(f, "Beeble API: Rate limited"), + Self::GenerationFailed(msg) => write!(f, "Beeble API: Generation failed: {}", msg), + } + } +} diff --git a/crates/api_clients/beeble_client/src/error/mod.rs b/crates/api_clients/beeble_client/src/error/mod.rs new file mode 100644 index 0000000000..6512780cfa --- /dev/null +++ b/crates/api_clients/beeble_client/src/error/mod.rs @@ -0,0 +1,4 @@ +pub mod beeble_client_error; +pub mod beeble_error; +pub mod beeble_generic_api_error; +pub mod beeble_specific_api_error; diff --git a/crates/api_clients/beeble_client/src/lib.rs b/crates/api_clients/beeble_client/src/lib.rs new file mode 100644 index 0000000000..e9c19b8190 --- /dev/null +++ b/crates/api_clients/beeble_client/src/lib.rs @@ -0,0 +1,7 @@ +#[cfg(test)] +pub(crate) mod test_utils; + +// Public API +pub mod creds; +pub mod error; +pub mod requests; diff --git a/crates/api_clients/beeble_client/src/requests/create_upload_url/create_upload_url.rs b/crates/api_clients/beeble_client/src/requests/create_upload_url/create_upload_url.rs new file mode 100644 index 0000000000..f4427aa4fe --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/create_upload_url/create_upload_url.rs @@ -0,0 +1,126 @@ +use log::info; + +use crate::creds::beeble_api_key::BeebleApiKey; +use crate::error::beeble_client_error::BeebleClientError; +use crate::error::beeble_error::BeebleError; +use crate::error::beeble_generic_api_error::BeebleGenericApiError; +use crate::error::beeble_specific_api_error::BeebleSpecificApiError; +use crate::requests::create_upload_url::request_types::*; + +const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1"; + +// ── Public args ── + +pub struct CreateUploadUrlArgs { + pub api_key: BeebleApiKey, + pub filename: String, +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct CreateUploadUrlSuccess { + /// Upload ID (upload_...). + pub id: String, + /// Presigned PUT URL for uploading. Expires after 1 hour. + pub upload_url: String, + /// beeble:// URI to reference this upload in generation calls. + pub beeble_uri: String, +} + +// ── Implementation ── + +/// Create a presigned upload URL for a media file. +/// +/// After receiving the URL, PUT your file bytes directly to `upload_url`. +/// Then use `beeble_uri` as the source_uri, reference_image_uri, or alpha_uri +/// in generation calls. +pub async fn create_upload_url(args: CreateUploadUrlArgs) -> Result { + let url = format!("{}/uploads", BEEBLE_API_BASE_URL); + + info!("Creating Beeble upload URL for filename: {}", args.filename); + + let request_body = CreateUploadUrlRequestBody { + filename: args.filename, + }; + + let client = reqwest::Client::builder() + .build() + .map_err(|err| BeebleClientError::ReqwestClientError(err))?; + + let response = client.post(&url) + .header("x-api-key", &args.api_key.api_key) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(|err| BeebleGenericApiError::ReqwestError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| BeebleGenericApiError::ReqwestError(err))?; + + info!("Beeble create upload URL response: status={}", status); + + if status == reqwest::StatusCode::UNAUTHORIZED { + return Err(BeebleSpecificApiError::Unauthorized.into()); + } + + if !status.is_success() { + return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + + let parsed: CreateUploadUrlResponseBody = serde_json::from_str(&response_body) + .map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?; + + Ok(CreateUploadUrlSuccess { + id: parsed.id, + upload_url: parsed.upload_url, + beeble_uri: parsed.beeble_uri, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::get_test_api_key::get_test_api_key; + use errors::AnyhowResult; + + #[test] + fn request_body_serializes() { + let body = CreateUploadUrlRequestBody { + filename: "test_video.mp4".to_string(), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("test_video.mp4")); + } + + #[test] + fn response_body_deserializes() { + let json = r#"{"id":"upload_abc123","upload_url":"https://example.com/put","beeble_uri":"beeble://upload_abc123/video.mp4"}"#; + let parsed: CreateUploadUrlResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.id, "upload_abc123"); + assert_eq!(parsed.beeble_uri, "beeble://upload_abc123/video.mp4"); + } + + #[tokio::test] + #[ignore] // manually test — requires real API key + async fn test_create_upload_url() -> AnyhowResult<()> { + let api_key = get_test_api_key()?; + let result = create_upload_url(CreateUploadUrlArgs { + api_key, + filename: "test_image.png".to_string(), + }).await?; + + println!("Upload ID: {}", result.id); + println!("Upload URL: {}", result.upload_url); + println!("Beeble URI: {}", result.beeble_uri); + assert!(result.id.starts_with("upload_")); + assert!(result.beeble_uri.starts_with("beeble://")); + Ok(()) + } +} diff --git a/crates/api_clients/beeble_client/src/requests/create_upload_url/mod.rs b/crates/api_clients/beeble_client/src/requests/create_upload_url/mod.rs new file mode 100644 index 0000000000..44f8b9de30 --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/create_upload_url/mod.rs @@ -0,0 +1,2 @@ +pub mod create_upload_url; +mod request_types; diff --git a/crates/api_clients/beeble_client/src/requests/create_upload_url/request_types.rs b/crates/api_clients/beeble_client/src/requests/create_upload_url/request_types.rs new file mode 100644 index 0000000000..709728997e --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/create_upload_url/request_types.rs @@ -0,0 +1,13 @@ +use serde_derive::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct CreateUploadUrlRequestBody { + pub filename: String, +} + +#[derive(Deserialize, Debug)] +pub struct CreateUploadUrlResponseBody { + pub id: String, + pub upload_url: String, + pub beeble_uri: String, +} diff --git a/crates/api_clients/beeble_client/src/requests/get_job_status/get_job_status.rs b/crates/api_clients/beeble_client/src/requests/get_job_status/get_job_status.rs new file mode 100644 index 0000000000..a775ae7f90 --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/get_job_status/get_job_status.rs @@ -0,0 +1,90 @@ +use log::info; + +use crate::creds::beeble_api_key::BeebleApiKey; +use crate::error::beeble_client_error::BeebleClientError; +use crate::error::beeble_error::BeebleError; +use crate::error::beeble_generic_api_error::BeebleGenericApiError; +use crate::error::beeble_specific_api_error::BeebleSpecificApiError; +use crate::requests::start_generation::request_types::GenerationJobResponseBody; +use crate::requests::start_generation::start_generation::{map_job_response, StartGenerationSuccess}; + +const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1"; + +// ── Public args ── + +pub struct GetJobStatusArgs { + pub api_key: BeebleApiKey, + /// The job ID (swx_...) to poll. + pub job_id: String, +} + +// ── Implementation ── + +/// Poll the status of a SwitchX generation job. +/// +/// Returns the same response shape as start_generation. +/// Poll until `status` is "completed" or "failed". +pub async fn get_job_status(args: GetJobStatusArgs) -> Result { + let url = format!("{}/switchx/generations/{}", BEEBLE_API_BASE_URL, args.job_id); + + info!("Polling Beeble job status: job_id={}", args.job_id); + + let client = reqwest::Client::builder() + .build() + .map_err(|err| BeebleClientError::ReqwestClientError(err))?; + + let response = client.get(&url) + .header("x-api-key", &args.api_key.api_key) + .send() + .await + .map_err(|err| BeebleGenericApiError::ReqwestError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| BeebleGenericApiError::ReqwestError(err))?; + + info!("Beeble get job status response: status={}", status); + + match status.as_u16() { + 401 => return Err(BeebleSpecificApiError::Unauthorized.into()), + 429 => return Err(BeebleSpecificApiError::RateLimited.into()), + _ if !status.is_success() => { + return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + _ => {} + } + + let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body) + .map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?; + + Ok(map_job_response(parsed)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore] // manually test — requires real API key and an existing job ID + async fn test_poll_job_status() -> errors::AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + + let api_key = get_test_api_key()?; + let result = get_job_status(GetJobStatusArgs { + api_key, + job_id: "swx_REPLACE_WITH_REAL_JOB_ID".to_string(), + }).await?; + + println!("Job ID: {}", result.id); + println!("Status: {}", result.status); + println!("Progress: {:?}", result.progress); + if let Some(output) = &result.output { + println!("Render URL: {:?}", output.render); + } + Ok(()) + } +} diff --git a/crates/api_clients/beeble_client/src/requests/get_job_status/mod.rs b/crates/api_clients/beeble_client/src/requests/get_job_status/mod.rs new file mode 100644 index 0000000000..cb3bdb89ce --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/get_job_status/mod.rs @@ -0,0 +1 @@ +pub mod get_job_status; diff --git a/crates/api_clients/beeble_client/src/requests/mod.rs b/crates/api_clients/beeble_client/src/requests/mod.rs new file mode 100644 index 0000000000..3900ac031a --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/mod.rs @@ -0,0 +1,3 @@ +pub mod create_upload_url; +pub mod get_job_status; +pub mod start_generation; diff --git a/crates/api_clients/beeble_client/src/requests/start_generation/mod.rs b/crates/api_clients/beeble_client/src/requests/start_generation/mod.rs new file mode 100644 index 0000000000..a56cdcc495 --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/start_generation/mod.rs @@ -0,0 +1,2 @@ +pub mod start_generation; +pub(crate) mod request_types; diff --git a/crates/api_clients/beeble_client/src/requests/start_generation/request_types.rs b/crates/api_clients/beeble_client/src/requests/start_generation/request_types.rs new file mode 100644 index 0000000000..7f61f0820f --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/start_generation/request_types.rs @@ -0,0 +1,55 @@ +use serde_derive::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct StartGenerationRequestBody { + pub generation_type: String, + pub source_uri: String, + pub alpha_mode: String, + + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub reference_image_uri: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub alpha_uri: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub max_resolution: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub callback_url: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub idempotency_key: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct GenerationJobResponseBody { + pub id: String, + pub status: String, + pub progress: Option, + pub generation_type: Option, + pub alpha_mode: Option, + pub output: Option, + pub error: Option, + pub created_at: Option, + pub modified_at: Option, + pub completed_at: Option, + pub webhook: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct GenerationOutput { + pub render: Option, + pub source: Option, + pub alpha: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct WebhookStatus { + pub status: Option, + pub attempts: Option, + pub last_error: Option, +} diff --git a/crates/api_clients/beeble_client/src/requests/start_generation/start_generation.rs b/crates/api_clients/beeble_client/src/requests/start_generation/start_generation.rs new file mode 100644 index 0000000000..5ef5ba0f3b --- /dev/null +++ b/crates/api_clients/beeble_client/src/requests/start_generation/start_generation.rs @@ -0,0 +1,292 @@ +use log::info; + +use crate::creds::beeble_api_key::BeebleApiKey; +use crate::error::beeble_client_error::BeebleClientError; +use crate::error::beeble_error::BeebleError; +use crate::error::beeble_generic_api_error::BeebleGenericApiError; +use crate::error::beeble_specific_api_error::BeebleSpecificApiError; +use crate::requests::start_generation::request_types::*; + +const BEEBLE_API_BASE_URL: &str = "https://api.beeble.ai/v1"; + +// ── Public types ── + +#[derive(Clone, Debug)] +pub struct StartGenerationArgs { + pub api_key: BeebleApiKey, + pub request: StartGenerationRequest, +} + +#[derive(Clone, Debug)] +pub struct StartGenerationRequest { + /// "image" or "video". + pub generation_type: BeebleGenerationType, + /// Source image/video URI. Supports beeble://, https://, data: base64. + pub source_uri: String, + /// Alpha matte mode. + pub alpha_mode: BeebleAlphaMode, + + /// Text description of desired output (max 2000 chars). + pub prompt: Option, + /// URI for style transfer reference image. + pub reference_image_uri: Option, + /// Custom alpha matte URI. Required for Custom and Select modes. + pub alpha_uri: Option, + /// Maximum output resolution: 720 or 1080. Default 1080. + pub max_resolution: Option, + /// HTTPS webhook URL for completion notification. + pub callback_url: Option, + /// Safe retry key preventing duplicate jobs (max 256 chars). + pub idempotency_key: Option, +} + +#[derive(Copy, Clone, Debug)] +pub enum BeebleGenerationType { + Image, + Video, +} + +#[derive(Copy, Clone, Debug)] +pub enum BeebleAlphaMode { + /// Automatic alpha detection. + Auto, + /// Fill the entire frame. + Fill, + /// Use a custom alpha matte (requires alpha_uri). + Custom, + /// Select regions from the source (requires alpha_uri). + Select, +} + +// ── Public response ── + +#[derive(Debug, Clone)] +pub struct StartGenerationSuccess { + /// Job identifier (swx_...). + pub id: String, + /// in_queue, processing, completed, or failed. + pub status: String, + /// 0-100 progress percentage. + pub progress: Option, + pub generation_type: Option, + pub alpha_mode: Option, + pub output: Option, + pub error: Option, + pub created_at: Option, +} + +#[derive(Debug, Clone)] +pub struct GenerationOutputResult { + /// URL to the rendered output. Expires after 72 hours. + pub render: Option, + /// URL to the source. Expires after 72 hours. + pub source: Option, + /// URL to the alpha matte. Expires after 72 hours. + pub alpha: Option, +} + +// ── Implementation ── + +/// Start a SwitchX compositing generation job. +pub async fn start_generation(args: StartGenerationArgs) -> Result { + let url = format!("{}/switchx/generations", BEEBLE_API_BASE_URL); + let req = args.request; + + info!("Starting Beeble generation: type={:?}, alpha_mode={:?}", req.generation_type, req.alpha_mode); + + let request_body = StartGenerationRequestBody { + generation_type: match req.generation_type { + BeebleGenerationType::Image => "image".to_string(), + BeebleGenerationType::Video => "video".to_string(), + }, + source_uri: req.source_uri, + alpha_mode: match req.alpha_mode { + BeebleAlphaMode::Auto => "auto".to_string(), + BeebleAlphaMode::Fill => "fill".to_string(), + BeebleAlphaMode::Custom => "custom".to_string(), + BeebleAlphaMode::Select => "select".to_string(), + }, + prompt: req.prompt, + reference_image_uri: req.reference_image_uri, + alpha_uri: req.alpha_uri, + max_resolution: req.max_resolution, + callback_url: req.callback_url, + idempotency_key: req.idempotency_key, + }; + + let client = reqwest::Client::builder() + .build() + .map_err(|err| BeebleClientError::ReqwestClientError(err))?; + + let response = client.post(&url) + .header("x-api-key", &args.api_key.api_key) + .header("Content-Type", "application/json") + .json(&request_body) + .send() + .await + .map_err(|err| BeebleGenericApiError::ReqwestError(err))?; + + let status = response.status(); + let response_body = response.text() + .await + .map_err(|err| BeebleGenericApiError::ReqwestError(err))?; + + info!("Beeble start generation response: status={}", status); + + match status.as_u16() { + 401 => return Err(BeebleSpecificApiError::Unauthorized.into()), + 402 => return Err(BeebleSpecificApiError::InsufficientCredits.into()), + 409 => return Err(BeebleSpecificApiError::IdempotencyConflict.into()), + 429 => return Err(BeebleSpecificApiError::RateLimited.into()), + _ if !status.is_success() => { + return Err(BeebleGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code: status, + body: response_body, + }.into()); + } + _ => {} + } + + let parsed: GenerationJobResponseBody = serde_json::from_str(&response_body) + .map_err(|err| BeebleGenericApiError::SerdeResponseParseError(err, response_body.clone()))?; + + Ok(map_job_response(parsed)) +} + +pub(crate) fn map_job_response(parsed: GenerationJobResponseBody) -> StartGenerationSuccess { + StartGenerationSuccess { + id: parsed.id, + status: parsed.status, + progress: parsed.progress, + generation_type: parsed.generation_type, + alpha_mode: parsed.alpha_mode, + output: parsed.output.map(|o| GenerationOutputResult { + render: o.render, + source: o.source, + alpha: o.alpha, + }), + error: parsed.error, + created_at: parsed.created_at, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::requests::start_generation::request_types::GenerationJobResponseBody; + + #[test] + fn request_body_serializes_minimal() { + let body = StartGenerationRequestBody { + generation_type: "image".to_string(), + source_uri: "beeble://upload_abc/photo.jpg".to_string(), + alpha_mode: "auto".to_string(), + prompt: None, + reference_image_uri: None, + alpha_uri: None, + max_resolution: None, + callback_url: None, + idempotency_key: None, + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"generation_type\":\"image\"")); + assert!(json.contains("\"alpha_mode\":\"auto\"")); + // Optional fields should not be present + assert!(!json.contains("prompt")); + assert!(!json.contains("reference_image_uri")); + } + + #[test] + fn request_body_serializes_full() { + let body = StartGenerationRequestBody { + generation_type: "video".to_string(), + source_uri: "https://example.com/video.mp4".to_string(), + alpha_mode: "custom".to_string(), + prompt: Some("A sunny beach".to_string()), + reference_image_uri: Some("beeble://upload_ref/ref.jpg".to_string()), + alpha_uri: Some("beeble://upload_alpha/mask.png".to_string()), + max_resolution: Some(1080), + callback_url: Some("https://example.com/webhook".to_string()), + idempotency_key: Some("my-key-123".to_string()), + }; + let json = serde_json::to_string(&body).unwrap(); + assert!(json.contains("\"prompt\":\"A sunny beach\"")); + assert!(json.contains("\"max_resolution\":1080")); + assert!(json.contains("\"idempotency_key\":\"my-key-123\"")); + } + + #[test] + fn response_body_deserializes_queued() { + let json = r#"{ + "id": "swx_abc123", + "status": "in_queue", + "progress": null, + "generation_type": "image", + "alpha_mode": "auto", + "output": null, + "error": null, + "created_at": "2026-05-08T00:00:00Z", + "modified_at": "2026-05-08T00:00:00Z", + "completed_at": null, + "webhook": null + }"#; + let parsed: GenerationJobResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.id, "swx_abc123"); + assert_eq!(parsed.status, "in_queue"); + assert!(parsed.output.is_none()); + } + + #[test] + fn response_body_deserializes_completed() { + let json = r#"{ + "id": "swx_abc123", + "status": "completed", + "progress": 100, + "generation_type": "image", + "alpha_mode": "auto", + "output": { + "render": "https://cdn.beeble.ai/render.png", + "source": "https://cdn.beeble.ai/source.png", + "alpha": "https://cdn.beeble.ai/alpha.png" + }, + "error": null, + "created_at": "2026-05-08T00:00:00Z", + "modified_at": "2026-05-08T00:00:01Z", + "completed_at": "2026-05-08T00:00:01Z", + "webhook": null + }"#; + let parsed: GenerationJobResponseBody = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.status, "completed"); + assert_eq!(parsed.progress, Some(100)); + let output = parsed.output.unwrap(); + assert!(output.render.unwrap().contains("render.png")); + } + + #[tokio::test] + #[ignore] // manually test — requires real API key and incurs costs + async fn test_start_image_generation() -> errors::AnyhowResult<()> { + use crate::test_utils::get_test_api_key::get_test_api_key; + + let api_key = get_test_api_key()?; + let result = start_generation(StartGenerationArgs { + api_key, + request: StartGenerationRequest { + generation_type: BeebleGenerationType::Image, + source_uri: "https://images.unsplash.com/photo-1506748686214-e9df14d4d9d0?w=800".to_string(), + alpha_mode: BeebleAlphaMode::Auto, + prompt: Some("A tropical beach at sunset".to_string()), + reference_image_uri: None, + alpha_uri: None, + max_resolution: Some(720), + callback_url: None, + idempotency_key: None, + }, + }).await?; + + println!("Job ID: {}", result.id); + println!("Status: {}", result.status); + println!("Progress: {:?}", result.progress); + assert!(result.id.starts_with("swx_")); + Ok(()) + } +} diff --git a/crates/api_clients/beeble_client/src/test_utils/get_test_api_key.rs b/crates/api_clients/beeble_client/src/test_utils/get_test_api_key.rs new file mode 100644 index 0000000000..34c3465a84 --- /dev/null +++ b/crates/api_clients/beeble_client/src/test_utils/get_test_api_key.rs @@ -0,0 +1,11 @@ +use errors::AnyhowResult; +use std::fs::read_to_string; + +use crate::creds::beeble_api_key::BeebleApiKey; + +#[cfg(test)] +pub fn get_test_api_key() -> AnyhowResult { + let api_key = read_to_string("/Users/bt/Artcraft/credentials/beeble_api_key.txt")?; + let api_key = api_key.trim().to_string(); + Ok(BeebleApiKey::new(api_key)) +} diff --git a/crates/api_clients/beeble_client/src/test_utils/mod.rs b/crates/api_clients/beeble_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..98dab99438 --- /dev/null +++ b/crates/api_clients/beeble_client/src/test_utils/mod.rs @@ -0,0 +1 @@ +pub mod get_test_api_key;