diff --git a/Cargo.lock b/Cargo.lock index f8a6682dec..bda969e59f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2377,6 +2377,19 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "comet_api_client" +version = "0.0.1" +dependencies = [ + "log", + "reqwest 0.12.23", + "serde", + "serde_derive", + "serde_json", + "tokio", + "workspace-build-acceleration", +] + [[package]] name = "compact_str" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index 43222a92b2..f53286d3d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/api_clients/artcraft/artcraft_client", "crates/api_clients/artcraft/artcraft_router", "crates/api_clients/cloud_storage", + "crates/api_clients/comet_api_client", "crates/api_clients/datadog_client", "crates/api_clients/fal_client", "crates/api_clients/google_drive_downloader", diff --git a/crates/api_clients/comet_api_client/Cargo.toml b/crates/api_clients/comet_api_client/Cargo.toml new file mode 100644 index 0000000000..39f9b4233e --- /dev/null +++ b/crates/api_clients/comet_api_client/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "comet_api_client" +edition = "2024" +version = "0.0.1" +publish = false + +[package.metadata.cargo-udeps.ignore] +normal = ["workspace-build-acceleration"] + +[lib] +name = "comet_api_client" +path = "src/lib.rs" + +[dependencies] +# Workspace hack (faster compile times) +workspace-build-acceleration.workspace = true + +# External +log.workspace = true +# `multipart` is enabled on top of the workspace feature set because the +# CometAPI video creation endpoint takes `multipart/form-data` (the +# `input_reference` image fields are binary parts). +reqwest = { workspace = true, features = ["multipart"] } +serde.workspace = true +serde_derive.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tokio = { version = "1", features = ["full"] } diff --git a/crates/api_clients/comet_api_client/src/creds/comet_api_key.rs b/crates/api_clients/comet_api_client/src/creds/comet_api_key.rs new file mode 100644 index 0000000000..19379503c8 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/creds/comet_api_key.rs @@ -0,0 +1,17 @@ +/// A CometAPI key used for Bearer token authentication. +#[derive(Clone)] +pub struct CometApiKey(pub String); + +impl CometApiKey { + pub fn new(api_key: String) -> Self { + Self(api_key.trim().to_string()) + } + + pub fn from_str(api_key: &str) -> Self { + Self(api_key.trim().to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/crates/api_clients/comet_api_client/src/creds/mod.rs b/crates/api_clients/comet_api_client/src/creds/mod.rs new file mode 100644 index 0000000000..f9509f584b --- /dev/null +++ b/crates/api_clients/comet_api_client/src/creds/mod.rs @@ -0,0 +1 @@ +pub mod comet_api_key; diff --git a/crates/api_clients/comet_api_client/src/error/categorize_comet_error.rs b/crates/api_clients/comet_api_client/src/error/categorize_comet_error.rs new file mode 100644 index 0000000000..886309bd3c --- /dev/null +++ b/crates/api_clients/comet_api_client/src/error/categorize_comet_error.rs @@ -0,0 +1,157 @@ +use reqwest::StatusCode; + +use crate::error::comet_bad_request_api_error::CometBadRequestApiError; +use crate::error::comet_error::CometError; +use crate::error::comet_generic_api_error::CometGenericApiError; +use crate::error::comet_specific_api_error::CometSpecificApiError; + +/// Marker CometAPI returns from `GET /v1/videos/{id}` for unknown task ids. +const TASK_NOT_EXIST_MARKER: &str = "task_not_exist"; + +/// Categorize a non-success response from the video creation endpoint +/// (`POST /v1/videos`). +/// +/// Per the docs, 400s from this endpoint mean the request itself was invalid +/// (missing prompt, out-of-range duration, image input on an unsupported +/// model) — the caller's fault, surfaced as [`CometBadRequestApiError`]. +pub fn categorize_create_video_error( + status_code: StatusCode, + body: String, +) -> CometError { + if status_code == StatusCode::UNAUTHORIZED { + return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into(); + } + + if status_code == StatusCode::BAD_REQUEST { + return CometBadRequestApiError::InvalidRequestParameters { raw_body: body }.into(); + } + + CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body, + }.into() +} + +/// Categorize a non-success response from the poll endpoint +/// (`GET /v1/videos/{id}`). +/// +/// Unlike the creation endpoint, a 400 here means the task id is unknown +/// ("task_not_exist") — a specific, well-known cause rather than a +/// user-facing bad request. +pub fn categorize_get_video_task_error( + status_code: StatusCode, + body: String, + task_id: &str, +) -> CometError { + if status_code == StatusCode::UNAUTHORIZED { + return CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body: body }.into(); + } + + if status_code == StatusCode::BAD_REQUEST && body.contains(TASK_NOT_EXIST_MARKER) { + return CometSpecificApiError::TaskNotFound { + task_id: task_id.to_string(), + raw_body: body, + }.into(); + } + + CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { + status_code, + body, + }.into() +} + +#[cfg(test)] +mod tests { + use super::*; + + mod create_video_tests { + use super::*; + + #[test] + fn bad_request_is_caller_fault() { + let body = r#"{"error":{"message":"seconds must be between 4 and 15","type":"invalid_request_error"}}"#.to_string(); + + let error = categorize_create_video_error(StatusCode::BAD_REQUEST, body.clone()); + + match error { + CometError::ApiBadRequest(CometBadRequestApiError::InvalidRequestParameters { raw_body }) => { + assert_eq!(raw_body, body); + } + other => panic!("Expected InvalidRequestParameters, got: {:?}", other), + } + } + + #[test] + fn unauthorized_is_specific() { + let body = r#"{"error":{"message":"invalid token","type":"cometapi_error"}}"#.to_string(); + + let error = categorize_create_video_error(StatusCode::UNAUTHORIZED, body); + + match error { + CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { raw_body }) => { + assert!(raw_body.contains("invalid token")); + } + other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other), + } + } + + #[test] + fn server_errors_fall_back_to_uncategorized() { + let error = categorize_create_video_error( + StatusCode::BAD_GATEWAY, + "upstream timeout".to_string(), + ); + + match &error { + CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, .. }) => { + assert_eq!(*status_code, StatusCode::BAD_GATEWAY); + } + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + assert!(error.is_having_downtime_issues()); + } + } + + mod get_video_task_tests { + use super::*; + + #[test] + fn task_not_exist_is_task_not_found() { + let body = r#"{"error":{"message":"task_not_exist","type":"cometapi_error"}}"#.to_string(); + + let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123"); + + match error { + CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, raw_body }) => { + assert_eq!(task_id, "task_abc123"); + assert!(raw_body.contains("task_not_exist")); + } + other => panic!("Expected TaskNotFound, got: {:?}", other), + } + } + + #[test] + fn other_bad_requests_fall_back_to_uncategorized() { + let body = r#"{"error":{"message":"something else"}}"#.to_string(); + + let error = categorize_get_video_task_error(StatusCode::BAD_REQUEST, body, "task_abc123"); + + match error { + CometError::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }) => {} + other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other), + } + } + + #[test] + fn unauthorized_is_specific() { + let body = r#"{"error":{"message":"invalid token"}}"#.to_string(); + + let error = categorize_get_video_task_error(StatusCode::UNAUTHORIZED, body, "task_abc123"); + + match error { + CometError::ApiSpecific(CometSpecificApiError::UnauthorizedInvalidApiKey { .. }) => {} + other => panic!("Expected UnauthorizedInvalidApiKey, got: {:?}", other), + } + } + } +} diff --git a/crates/api_clients/comet_api_client/src/error/comet_bad_request_api_error.rs b/crates/api_clients/comet_api_client/src/error/comet_bad_request_api_error.rs new file mode 100644 index 0000000000..46113d90c9 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/error/comet_bad_request_api_error.rs @@ -0,0 +1,22 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// These represent known "400s" that are the fault of the client sending a +/// bad request. They should surface to the end user as 400s. +#[derive(Debug)] +pub enum CometBadRequestApiError { + /// The video creation request was rejected for invalid parameters (eg. + /// missing prompt, out-of-range duration, an `input_reference` image on a + /// model that doesn't support image input). + InvalidRequestParameters { raw_body: String }, +} + +impl Error for CometBadRequestApiError {} + +impl Display for CometBadRequestApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidRequestParameters { raw_body } => write!(f, "Invalid request parameters: {}", raw_body), + } + } +} diff --git a/crates/api_clients/comet_api_client/src/error/comet_client_error.rs b/crates/api_clients/comet_api_client/src/error/comet_client_error.rs new file mode 100644 index 0000000000..831b2cbd56 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/error/comet_client_error.rs @@ -0,0 +1,26 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum CometClientError { + /// An error was encountered building the reqwest client or the request. + ReqwestClientError(reqwest::Error), + + /// A request field couldn't be validated or converted to the value + /// expected on the wire. Carries the field name and the raw input the + /// caller supplied. + InvalidRequestField { field: &'static str, raw_value: String, reason: String }, +} + +impl Error for CometClientError {} + +impl Display for CometClientError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::ReqwestClientError(err) => write!(f, "Reqwest client error: {}", err), + Self::InvalidRequestField { field, raw_value, reason } => { + write!(f, "Invalid value for request field `{}`: {:?} ({})", field, raw_value, reason) + } + } + } +} diff --git a/crates/api_clients/comet_api_client/src/error/comet_error.rs b/crates/api_clients/comet_api_client/src/error/comet_error.rs new file mode 100644 index 0000000000..959d747a11 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/error/comet_error.rs @@ -0,0 +1,71 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::error::comet_bad_request_api_error::CometBadRequestApiError; +use crate::error::comet_client_error::CometClientError; +use crate::error::comet_generic_api_error::CometGenericApiError; +use crate::error::comet_specific_api_error::CometSpecificApiError; + +#[derive(Debug)] +pub enum CometError { + /// An error that happened client-side (before or while making the request). + Client(CometClientError), + + /// Known "400s" that are the fault of the client sending a bad request. + /// These should surface to the end user as 400s. + ApiBadRequest(CometBadRequestApiError), + + /// Server-side errors with specific, well-known causes. + ApiSpecific(CometSpecificApiError), + + /// Server-side errors with unknown / grab-bag causes. + ApiGeneric(CometGenericApiError), +} + +impl CometError { + pub fn is_having_downtime_issues(&self) -> bool { + match self { + Self::ApiGeneric(CometGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: _ }) => { + matches!(status_code.as_u16(), 502 | 503 | 504 | 524) + }, + _ => false, + } + } +} + +impl Error for CometError {} + +impl Display for CometError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::Client(e) => write!(f, "CometClientError: {:?}", e), + Self::ApiBadRequest(e) => write!(f, "CometBadRequestApiError: {:?}", e), + Self::ApiSpecific(e) => write!(f, "CometSpecificApiError: {:?}", e), + Self::ApiGeneric(e) => write!(f, "CometGenericApiError: {:?}", e), + } + } +} + +impl From for CometError { + fn from(error: CometClientError) -> Self { + Self::Client(error) + } +} + +impl From for CometError { + fn from(error: CometBadRequestApiError) -> Self { + Self::ApiBadRequest(error) + } +} + +impl From for CometError { + fn from(error: CometSpecificApiError) -> Self { + Self::ApiSpecific(error) + } +} + +impl From for CometError { + fn from(error: CometGenericApiError) -> Self { + Self::ApiGeneric(error) + } +} diff --git a/crates/api_clients/comet_api_client/src/error/comet_generic_api_error.rs b/crates/api_clients/comet_api_client/src/error/comet_generic_api_error.rs new file mode 100644 index 0000000000..540e6848d2 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/error/comet_generic_api_error.rs @@ -0,0 +1,33 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use reqwest::StatusCode; + +/// Server-side errors with unknown / grab-bag causes. +#[derive(Debug)] +pub enum CometGenericApiError { + /// serde_json::Error, likely from a JSON deserialization schema mismatch. + /// Includes the original body. + 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 (network failures, timeouts, etc.) + ReqwestError(reqwest::Error), +} + +impl Error for CometGenericApiError {} + +impl Display for CometGenericApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::SerdeResponseParseErrorWithBody(err, body) => write!(f, "Failed to parse response body: {:?}. Body: {}", err, body), + Self::UncategorizedBadResponseWithStatusAndBody { status_code, body } => write!(f, "Uncategorized bad response: status code {}, body: {}", status_code, body), + Self::ReqwestError(err) => write!(f, "Reqwest error: {}", err), + } + } +} diff --git a/crates/api_clients/comet_api_client/src/error/comet_specific_api_error.rs b/crates/api_clients/comet_api_client/src/error/comet_specific_api_error.rs new file mode 100644 index 0000000000..b5ebf2e4c3 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/error/comet_specific_api_error.rs @@ -0,0 +1,23 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +/// Server-side errors with specific, well-known causes. +#[derive(Debug)] +pub enum CometSpecificApiError { + /// 401: the API key is missing or invalid. + UnauthorizedInvalidApiKey { raw_body: String }, + + /// The poll endpoint responded "task_not_exist" for the given task id. + TaskNotFound { task_id: String, raw_body: String }, +} + +impl Error for CometSpecificApiError {} + +impl Display for CometSpecificApiError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnauthorizedInvalidApiKey { raw_body } => write!(f, "Unauthorized: invalid or missing API key: {}", raw_body), + Self::TaskNotFound { task_id, raw_body } => write!(f, "Task not found: {} ({})", task_id, raw_body), + } + } +} diff --git a/crates/api_clients/comet_api_client/src/error/mod.rs b/crates/api_clients/comet_api_client/src/error/mod.rs new file mode 100644 index 0000000000..6f82605ccb --- /dev/null +++ b/crates/api_clients/comet_api_client/src/error/mod.rs @@ -0,0 +1,6 @@ +pub mod categorize_comet_error; +pub mod comet_bad_request_api_error; +pub mod comet_client_error; +pub mod comet_error; +pub mod comet_generic_api_error; +pub mod comet_specific_api_error; diff --git a/crates/api_clients/comet_api_client/src/lib.rs b/crates/api_clients/comet_api_client/src/lib.rs new file mode 100644 index 0000000000..1baa63ef76 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/lib.rs @@ -0,0 +1,20 @@ +//! comet_api_client +//! +//! HTTP client for CometAPI (https://www.cometapi.com/), which proxies a +//! variety of third-party generative models. We use it for Doubao Seedance 2.0 +//! video generation ("Seedance 2.0" and "Seedance 2.0 Fast"). +//! +//! Flow: enqueue with [`requests::create_video`], then poll with +//! [`requests::get_video_task`] until the task reaches a terminal status. +//! +//! NB: There is deliberately no webhook module. CometAPI documents callbacks +//! as provider-specific pass-through events with no standardized payload, and +//! the Seedance video endpoint has no documented callback parameter. Comet's +//! own guidance is to "keep polling as the source of truth". + +pub mod creds; +pub mod error; +pub mod requests; + +#[cfg(test)] +mod test_utils; diff --git a/crates/api_clients/comet_api_client/src/requests/comet_host.rs b/crates/api_clients/comet_api_client/src/requests/comet_host.rs new file mode 100644 index 0000000000..c8049b0568 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/requests/comet_host.rs @@ -0,0 +1,2 @@ +/// Base URL for the CometAPI HTTP API. +pub const COMET_API_BASE_URL: &str = "https://api.cometapi.com"; diff --git a/crates/api_clients/comet_api_client/src/requests/create_video/create_video.rs b/crates/api_clients/comet_api_client/src/requests/create_video/create_video.rs new file mode 100644 index 0000000000..cb43b425cf --- /dev/null +++ b/crates/api_clients/comet_api_client/src/requests/create_video/create_video.rs @@ -0,0 +1,430 @@ +use std::fmt; + +use log::info; +use reqwest::multipart::{Form, Part}; + +use crate::creds::comet_api_key::CometApiKey; +use crate::error::categorize_comet_error::categorize_create_video_error; +use crate::error::comet_client_error::CometClientError; +use crate::error::comet_error::CometError; +use crate::error::comet_generic_api_error::CometGenericApiError; +use crate::requests::comet_host::COMET_API_BASE_URL; +use crate::requests::create_video::request_types::CreateVideoRawResponse; +use crate::requests::video_task_status::CometVideoTaskStatus; + +/// Seedance 2.0 models support 4-15 second durations. +const MIN_DURATION_SECONDS: u8 = 4; +const MAX_DURATION_SECONDS: u8 = 15; + +// ── Public args ── + +/// Top-level argument to [`create_video`]. Borrows the API key separately +/// from the request body so callers can log/save [`CreateVideoRequest`] +/// without leaking the credential. +pub struct CreateVideoArgs<'a> { + pub api_key: &'a CometApiKey, + pub request: CreateVideoRequest, +} + +/// The material part of a video creation request (`POST /v1/videos`). +#[derive(Clone)] +pub struct CreateVideoRequest { + /// Which Doubao Seedance model fulfils the request. + pub model: DoubaoSeedance2p0Model, + + /// Text description of the desired video. Reference attached images in + /// the prompt as `[Image 1]`, `[Image 2]`, etc. + pub prompt: String, + + /// Video duration in seconds (4-15 for Seedance 2.0 models). Default: 5. + pub maybe_seconds: Option, + + /// Output size: an aspect-ratio preset or exact dimensions. + pub maybe_size: Option, + + /// Optional reference images (JPEG, PNG, WebP) for image-to-video. + /// Each is sent as a repeated `input_reference` multipart field. + pub input_reference_images: Vec, +} + +/// The Doubao Seedance 2.0 models CometAPI fronts. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum DoubaoSeedance2p0Model { + /// Seedance 2.0 + DoubaoSeedance2p0, + /// Seedance 2.0 Fast + DoubaoSeedance2p0Fast, +} + +/// Output video size: a ratio preset or exact pixel dimensions. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum CometVideoSize { + /// 16:9 (landscape) + Landscape16x9, + /// 4:3 + Standard4x3, + /// 1:1 (square) + Square, + /// 3:4 + Portrait3x4, + /// 9:16 (portrait) + Portrait9x16, + /// 21:9 (ultra-wide) + UltraWide21x9, + /// Exact dimensions, eg. 1280x720. + Exact { width: u32, height: u32 }, +} + +/// A reference image attached to the request as a binary multipart part. +#[derive(Clone)] +pub struct CometInputReferenceImage { + /// Raw bytes of the image (JPEG, PNG, or WebP). Excluded from `Debug` + /// output — a megabyte-scale blob shouldn't end up in a log line. + pub file_bytes: Vec, + + /// Filename for the multipart `Content-Disposition` header. + pub filename: String, + + /// Optional MIME type, eg. "image/png". If `None`, reqwest uses + /// `application/octet-stream`. + pub maybe_content_type: Option, +} + +// ── Public response ── + +#[derive(Clone, Debug)] +pub struct CreateVideoSuccess { + /// Task identifier. Poll `GET /v1/videos/{task_id}` until terminal. + pub task_id: String, + + /// Initial lifecycle status, typically queued. + pub status: CometVideoTaskStatus, + + /// The model that will fulfil the task, as echoed by the API. + pub maybe_model: Option, + + /// 0-100 completion percentage. + pub maybe_progress: Option, + + /// Unix timestamp (seconds). + pub maybe_created_at: Option, +} + +/// Enqueue a Doubao Seedance 2.0 video generation task. +/// +/// CometAPI's video creation endpoint takes `multipart/form-data`. The task +/// id in the response is then polled with +/// [`crate::requests::get_video_task::get_video_task::get_video_task`]. +pub async fn create_video(args: CreateVideoArgs<'_>) -> Result { + let request = &args.request; + request.validate()?; + + let url = format!("{COMET_API_BASE_URL}/v1/videos"); + + let mut form = Form::new(); + for (field, value) in request.text_form_fields() { + form = form.text(field, value); + } + for image in &request.input_reference_images { + let mut part = Part::bytes(image.file_bytes.clone()) + .file_name(image.filename.clone()); + if let Some(content_type) = &image.maybe_content_type { + part = part.mime_str(content_type) + .map_err(|err| CometClientError::InvalidRequestField { + field: "input_reference_images.maybe_content_type", + raw_value: content_type.clone(), + reason: format!("invalid mime type: {err}"), + })?; + } + form = form.part("input_reference", part); + } + + let client = reqwest::Client::builder() + .build() + .map_err(CometClientError::ReqwestClientError)?; + + let response = client.post(&url) + .bearer_auth(args.api_key.as_str()) + .multipart(form) + .send() + .await + .map_err(CometGenericApiError::ReqwestError)?; + + let status_code = response.status(); + let body = response.text() + .await + .map_err(CometGenericApiError::ReqwestError)?; + + if !status_code.is_success() { + return Err(categorize_create_video_error(status_code, body)); + } + + let raw: CreateVideoRawResponse = serde_json::from_str(&body) + .map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?; + + info!("Comet video task enqueued: {} (status: {})", raw.id, raw.status); + + Ok(CreateVideoSuccess { + task_id: raw.id, + status: raw.status, + maybe_model: raw.model, + maybe_progress: raw.progress, + maybe_created_at: raw.created_at, + }) +} + +impl CreateVideoRequest { + /// The text fields of the multipart form, in wire order. Split out from + /// [`create_video`] so the form shape is testable without HTTP. + pub fn text_form_fields(&self) -> Vec<(&'static str, String)> { + let mut fields = vec![ + ("model", self.model.as_api_str().to_string()), + ("prompt", self.prompt.clone()), + ]; + if let Some(seconds) = self.maybe_seconds { + fields.push(("seconds", seconds.to_string())); + } + if let Some(size) = self.maybe_size { + fields.push(("size", size.as_api_string())); + } + fields + } + + fn validate(&self) -> Result<(), CometClientError> { + if self.prompt.trim().is_empty() { + return Err(CometClientError::InvalidRequestField { + field: "prompt", + raw_value: self.prompt.clone(), + reason: "prompt must not be empty".to_string(), + }); + } + + if let Some(seconds) = self.maybe_seconds { + if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&seconds) { + return Err(CometClientError::InvalidRequestField { + field: "seconds", + raw_value: seconds.to_string(), + reason: format!("duration must be {MIN_DURATION_SECONDS}-{MAX_DURATION_SECONDS} seconds for Seedance 2.0 models"), + }); + } + } + + Ok(()) + } +} + +impl DoubaoSeedance2p0Model { + pub fn as_api_str(&self) -> &'static str { + match self { + Self::DoubaoSeedance2p0 => "doubao-seedance-2-0", + Self::DoubaoSeedance2p0Fast => "doubao-seedance-2-0-fast", + } + } +} + +impl CometVideoSize { + pub fn as_api_string(&self) -> String { + match self { + Self::Landscape16x9 => "16:9".to_string(), + Self::Standard4x3 => "4:3".to_string(), + Self::Square => "1:1".to_string(), + Self::Portrait3x4 => "3:4".to_string(), + Self::Portrait9x16 => "9:16".to_string(), + Self::UltraWide21x9 => "21:9".to_string(), + Self::Exact { width, height } => format!("{width}x{height}"), + } + } +} + +impl fmt::Debug for CreateVideoRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CreateVideoRequest") + .field("model", &self.model) + .field("prompt", &self.prompt) + .field("maybe_seconds", &self.maybe_seconds) + .field("maybe_size", &self.maybe_size) + .field("input_reference_images", &self.input_reference_images) + .finish() + } +} + +impl fmt::Debug for CometInputReferenceImage { + // Print byte-length, not raw bytes — derived Debug on Vec would dump + // the entire image into the log line. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CometInputReferenceImage") + .field("file_bytes_len", &self.file_bytes.len()) + .field("filename", &self.filename) + .field("maybe_content_type", &self.maybe_content_type) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod shape_tests { + use super::*; + + #[test] + fn model_api_strings() { + assert_eq!(DoubaoSeedance2p0Model::DoubaoSeedance2p0.as_api_str(), "doubao-seedance-2-0"); + assert_eq!(DoubaoSeedance2p0Model::DoubaoSeedance2p0Fast.as_api_str(), "doubao-seedance-2-0-fast"); + } + + #[test] + fn size_api_strings() { + assert_eq!(CometVideoSize::Landscape16x9.as_api_string(), "16:9"); + assert_eq!(CometVideoSize::Standard4x3.as_api_string(), "4:3"); + assert_eq!(CometVideoSize::Square.as_api_string(), "1:1"); + assert_eq!(CometVideoSize::Portrait3x4.as_api_string(), "3:4"); + assert_eq!(CometVideoSize::Portrait9x16.as_api_string(), "9:16"); + assert_eq!(CometVideoSize::UltraWide21x9.as_api_string(), "21:9"); + assert_eq!(CometVideoSize::Exact { width: 1280, height: 720 }.as_api_string(), "1280x720"); + } + + #[test] + fn minimal_form_fields() { + let request = CreateVideoRequest { + model: DoubaoSeedance2p0Model::DoubaoSeedance2p0Fast, + prompt: "a corgi running through a field".to_string(), + maybe_seconds: None, + maybe_size: None, + input_reference_images: vec![], + }; + + assert_eq!(request.text_form_fields(), vec![ + ("model", "doubao-seedance-2-0-fast".to_string()), + ("prompt", "a corgi running through a field".to_string()), + ]); + } + + #[test] + fn full_form_fields() { + let request = CreateVideoRequest { + model: DoubaoSeedance2p0Model::DoubaoSeedance2p0, + prompt: "make [Image 1] come alive".to_string(), + maybe_seconds: Some(10), + maybe_size: Some(CometVideoSize::Portrait9x16), + input_reference_images: vec![], + }; + + assert_eq!(request.text_form_fields(), vec![ + ("model", "doubao-seedance-2-0".to_string()), + ("prompt", "make [Image 1] come alive".to_string()), + ("seconds", "10".to_string()), + ("size", "9:16".to_string()), + ]); + } + } + + mod validation_tests { + use super::*; + + #[test] + fn empty_prompt_is_rejected() { + let request = request_with_prompt_and_seconds(" ", None); + let error = request.validate().expect_err("should reject empty prompt"); + assert!(matches!(error, CometClientError::InvalidRequestField { field: "prompt", .. })); + } + + #[test] + fn duration_bounds() { + assert!(request_with_prompt_and_seconds("ok", Some(3)).validate().is_err()); + assert!(request_with_prompt_and_seconds("ok", Some(4)).validate().is_ok()); + assert!(request_with_prompt_and_seconds("ok", Some(15)).validate().is_ok()); + assert!(request_with_prompt_and_seconds("ok", Some(16)).validate().is_err()); + assert!(request_with_prompt_and_seconds("ok", None).validate().is_ok()); + } + + fn request_with_prompt_and_seconds(prompt: &str, maybe_seconds: Option) -> CreateVideoRequest { + CreateVideoRequest { + model: DoubaoSeedance2p0Model::DoubaoSeedance2p0, + prompt: prompt.to_string(), + maybe_seconds, + maybe_size: None, + input_reference_images: vec![], + } + } + } + + mod live_tests { + use super::*; + use crate::test_utils::load_api_key; + + /// Live test: enqueues a REAL generation and INCURS COSTS. + /// Run manually with: + /// cargo test -p comet_api_client live_create_video -- --ignored --nocapture + #[ignore] + #[tokio::test] + async fn live_create_video() { + let api_key = load_api_key(); + + let result = create_video(CreateVideoArgs { + api_key: &api_key, + request: CreateVideoRequest { + model: DoubaoSeedance2p0Model::DoubaoSeedance2p0Fast, + prompt: "a corgi running through a sunny meadow, cinematic".to_string(), + maybe_seconds: Some(4), + maybe_size: Some(CometVideoSize::Landscape16x9), + input_reference_images: vec![], + }, + }).await.expect("create_video should succeed"); + + println!("Enqueued task: {} (status: {})", result.task_id, result.status); + assert!(!result.task_id.is_empty()); + } + + /// Live test: enqueues a REAL generation and polls it to completion. + /// INCURS COSTS and takes a few minutes. Run manually with: + /// cargo test -p comet_api_client live_create_and_poll_video -- --ignored --nocapture + #[ignore] + #[tokio::test] + async fn live_create_and_poll_video() { + use std::time::Duration; + + use crate::requests::get_video_task::get_video_task::{get_video_task, GetVideoTaskArgs}; + + // NB: Comet's docs claim most tasks finish in 1-3 minutes, but tasks + // have been observed to sit at `in_progress` well past 10 minutes. + const POLL_INTERVAL: Duration = Duration::from_secs(15); + const MAX_POLLS: u32 = 120; // ~30 minutes + + let api_key = load_api_key(); + + let created = create_video(CreateVideoArgs { + api_key: &api_key, + request: CreateVideoRequest { + model: DoubaoSeedance2p0Model::DoubaoSeedance2p0Fast, + prompt: "a corgi running through a sunny meadow, cinematic".to_string(), + maybe_seconds: Some(4), + maybe_size: Some(CometVideoSize::Landscape16x9), + input_reference_images: vec![], + }, + }).await.expect("create_video should succeed"); + + println!("Enqueued task: {} (status: {})", created.task_id, created.status); + + for poll in 1..=MAX_POLLS { + tokio::time::sleep(POLL_INTERVAL).await; + + let task = get_video_task(GetVideoTaskArgs { + api_key: &api_key, + task_id: &created.task_id, + }).await.expect("get_video_task should succeed"); + + println!("Poll {}: status {} (progress: {:?})", poll, task.status, task.maybe_progress); + + if task.status.is_terminal() { + assert!(task.status.is_success(), "task should complete successfully: {:?}", task); + let video_url = task.maybe_video_url.expect("completed task should have a video url"); + println!("Video URL: {}", video_url); + return; + } + } + + panic!("Task {} did not reach a terminal state in time", created.task_id); + } + } +} diff --git a/crates/api_clients/comet_api_client/src/requests/create_video/mod.rs b/crates/api_clients/comet_api_client/src/requests/create_video/mod.rs new file mode 100644 index 0000000000..272583fe9c --- /dev/null +++ b/crates/api_clients/comet_api_client/src/requests/create_video/mod.rs @@ -0,0 +1,2 @@ +pub mod create_video; +pub mod request_types; diff --git a/crates/api_clients/comet_api_client/src/requests/create_video/request_types.rs b/crates/api_clients/comet_api_client/src/requests/create_video/request_types.rs new file mode 100644 index 0000000000..8676db32fe --- /dev/null +++ b/crates/api_clients/comet_api_client/src/requests/create_video/request_types.rs @@ -0,0 +1,77 @@ +use serde_derive::Deserialize; + +use crate::requests::video_task_status::CometVideoTaskStatus; + +/// Over-the-wire response from `POST /v1/videos`. +/// +/// The docs show both `id` and `task_id` carrying the same task identifier; +/// `task_id` is treated as an optional mirror of `id`. +#[derive(Clone, Debug, Deserialize)] +pub struct CreateVideoRawResponse { + /// Task identifier, eg. "task_abc123". Use this with the poll endpoint. + pub id: String, + + /// Mirror of `id`. + pub task_id: Option, + + /// Always "video". + pub object: Option, + + /// The model that will fulfil the task, eg. "doubao-seedance-2-0". + pub model: Option, + + /// Initial lifecycle status, typically "queued". + pub status: CometVideoTaskStatus, + + /// 0-100 completion percentage. + pub progress: Option, + + /// Unix timestamp (seconds). + pub created_at: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Documented response shape from + /// https://apidoc.cometapi.com/api/video/seedance/create.md + const REAL_CREATE_RESPONSE: &str = r#" +{ + "id": "task_abc123", + "task_id": "task_abc123", + "object": "video", + "model": "doubao-seedance-2-0", + "status": "queued", + "progress": 0, + "created_at": 1776681149 +} + "#; + + #[test] + fn parse_real_create_response() { + let response: CreateVideoRawResponse = serde_json::from_str(REAL_CREATE_RESPONSE) + .expect("should parse"); + + assert_eq!(response.id, "task_abc123"); + assert_eq!(response.task_id.as_deref(), Some("task_abc123")); + assert_eq!(response.object.as_deref(), Some("video")); + assert_eq!(response.model.as_deref(), Some("doubao-seedance-2-0")); + assert_eq!(response.status, CometVideoTaskStatus::Queued); + assert_eq!(response.progress, Some(0)); + assert_eq!(response.created_at, Some(1776681149)); + } + + #[test] + fn parse_minimal_response() { + // Only `id` and `status` are load-bearing; everything else is optional + // so schema drift doesn't break enqueuing. + let response: CreateVideoRawResponse = serde_json::from_str( + r#"{"id":"task_xyz","status":"queued"}"#, + ).expect("should parse"); + + assert_eq!(response.id, "task_xyz"); + assert_eq!(response.status, CometVideoTaskStatus::Queued); + assert_eq!(response.task_id, None); + } +} diff --git a/crates/api_clients/comet_api_client/src/requests/get_video_task/get_video_task.rs b/crates/api_clients/comet_api_client/src/requests/get_video_task/get_video_task.rs new file mode 100644 index 0000000000..78dc3af879 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/requests/get_video_task/get_video_task.rs @@ -0,0 +1,116 @@ +use log::info; + +use crate::creds::comet_api_key::CometApiKey; +use crate::error::categorize_comet_error::categorize_get_video_task_error; +use crate::error::comet_client_error::CometClientError; +use crate::error::comet_error::CometError; +use crate::error::comet_generic_api_error::CometGenericApiError; +use crate::requests::comet_host::COMET_API_BASE_URL; +use crate::requests::get_video_task::request_types::VideoTaskRawResponse; +use crate::requests::video_task_status::CometVideoTaskStatus; + +// ── Public args ── + +pub struct GetVideoTaskArgs<'a> { + pub api_key: &'a CometApiKey, + + /// The task id returned by the creation endpoint, eg. "task_abc123". + pub task_id: &'a str, +} + +// ── Public response ── + +/// Snapshot of a video task's state. +/// +/// Poll every 10-20 seconds until [`CometVideoTaskStatus::is_terminal`]; +/// most tasks finish within 1-3 minutes. +#[derive(Clone, Debug)] +pub struct CometVideoTask { + pub task_id: String, + + pub status: CometVideoTaskStatus, + + /// The Seedance model used, as echoed by the API. + pub maybe_model: Option, + + /// 0-100 completion percentage. + pub maybe_progress: Option, + + /// Signed download URL, present only when the task completed. The + /// signature is time-limited — download or re-host the file promptly. + pub maybe_video_url: Option, + + /// Unix timestamp (seconds). + pub maybe_created_at: Option, + + /// Unix timestamp (seconds) when the task finished. + pub maybe_completed_at: Option, +} + +/// Fetch the current state of a video task (`GET /v1/videos/{id}`). +pub async fn get_video_task(args: GetVideoTaskArgs<'_>) -> Result { + let url = format!("{COMET_API_BASE_URL}/v1/videos/{}", args.task_id); + + let client = reqwest::Client::builder() + .build() + .map_err(CometClientError::ReqwestClientError)?; + + let response = client.get(&url) + .bearer_auth(args.api_key.as_str()) + .send() + .await + .map_err(CometGenericApiError::ReqwestError)?; + + let status_code = response.status(); + let body = response.text() + .await + .map_err(CometGenericApiError::ReqwestError)?; + + if !status_code.is_success() { + return Err(categorize_get_video_task_error(status_code, body, args.task_id)); + } + + let raw: VideoTaskRawResponse = serde_json::from_str(&body) + .map_err(|err| CometGenericApiError::SerdeResponseParseErrorWithBody(err, body.clone()))?; + + info!("Comet video task {}: status {} (progress: {:?})", raw.id, raw.status, raw.progress); + + Ok(CometVideoTask { + task_id: raw.id, + status: raw.status, + maybe_model: raw.model, + maybe_progress: raw.progress, + maybe_video_url: raw.video_url, + maybe_created_at: raw.created_at, + maybe_completed_at: raw.completed_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::comet_specific_api_error::CometSpecificApiError; + use crate::test_utils::load_api_key; + + /// Live test: makes a REAL network request (no generation cost — the task + /// id is bogus, so this exercises auth + the TaskNotFound error path). + /// Run manually with: + /// cargo test -p comet_api_client live_get_video_task -- --ignored --nocapture + #[ignore] + #[tokio::test] + async fn live_get_video_task_unknown_id() { + let api_key = load_api_key(); + + let result = get_video_task(GetVideoTaskArgs { + api_key: &api_key, + task_id: "task_does_not_exist_artcraft_test", + }).await; + + match result { + Err(CometError::ApiSpecific(CometSpecificApiError::TaskNotFound { task_id, .. })) => { + assert_eq!(task_id, "task_does_not_exist_artcraft_test"); + } + other => panic!("Expected TaskNotFound, got: {:?}", other), + } + } +} diff --git a/crates/api_clients/comet_api_client/src/requests/get_video_task/mod.rs b/crates/api_clients/comet_api_client/src/requests/get_video_task/mod.rs new file mode 100644 index 0000000000..2ab7ec621e --- /dev/null +++ b/crates/api_clients/comet_api_client/src/requests/get_video_task/mod.rs @@ -0,0 +1,2 @@ +pub mod get_video_task; +pub mod request_types; diff --git a/crates/api_clients/comet_api_client/src/requests/get_video_task/request_types.rs b/crates/api_clients/comet_api_client/src/requests/get_video_task/request_types.rs new file mode 100644 index 0000000000..bc9bc28bfc --- /dev/null +++ b/crates/api_clients/comet_api_client/src/requests/get_video_task/request_types.rs @@ -0,0 +1,113 @@ +use serde_derive::Deserialize; + +use crate::requests::video_task_status::CometVideoTaskStatus; + +/// Over-the-wire response from `GET /v1/videos/{id}`. +#[derive(Clone, Debug, Deserialize)] +pub struct VideoTaskRawResponse { + /// Task identifier. + pub id: String, + + /// Always "video". + pub object: Option, + + /// The Seedance model used, eg. "doubao-seedance-2-0". + pub model: Option, + + /// Lifecycle status. + pub status: CometVideoTaskStatus, + + /// 0-100 completion percentage. + pub progress: Option, + + /// Signed download URL. Present only when `status` is "completed". + /// NB: the signature is time-limited — download or re-host the file + /// before it expires. + pub video_url: Option, + + /// Unix timestamp (seconds). + pub created_at: Option, + + /// Unix timestamp (seconds) when the task finished. + pub completed_at: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + // Documented response shapes from + // https://apidoc.cometapi.com/api/video/seedance/query.md + + const REAL_IN_PROGRESS_RESPONSE: &str = r#" +{ + "id": "task_abc123", + "object": "video", + "model": "doubao-seedance-2-0", + "status": "in_progress", + "progress": 30, + "created_at": 1777385418, + "completed_at": 1777385485 +} + "#; + + const REAL_COMPLETED_RESPONSE: &str = r#" +{ + "id": "task_abc123", + "object": "video", + "model": "doubao-seedance-2-0", + "status": "completed", + "progress": 100, + "created_at": 1777385418, + "completed_at": 1777385526, + "video_url": "https://example.com/seedance-output.mp4" +} + "#; + + const REAL_FAILED_RESPONSE: &str = r#" +{ + "id": "task_abc123", + "object": "video", + "model": "doubao-seedance-2-0", + "status": "failed", + "progress": 0, + "created_at": 1777385418, + "completed_at": 1777385526 +} + "#; + + #[test] + fn parse_real_in_progress_response() { + let response: VideoTaskRawResponse = serde_json::from_str(REAL_IN_PROGRESS_RESPONSE) + .expect("should parse"); + + assert_eq!(response.id, "task_abc123"); + assert_eq!(response.status, CometVideoTaskStatus::InProgress); + assert_eq!(response.progress, Some(30)); + assert_eq!(response.video_url, None); + assert!(!response.status.is_terminal()); + } + + #[test] + fn parse_real_completed_response() { + let response: VideoTaskRawResponse = serde_json::from_str(REAL_COMPLETED_RESPONSE) + .expect("should parse"); + + assert_eq!(response.status, CometVideoTaskStatus::Completed); + assert_eq!(response.progress, Some(100)); + assert_eq!(response.video_url.as_deref(), Some("https://example.com/seedance-output.mp4")); + assert_eq!(response.completed_at, Some(1777385526)); + assert!(response.status.is_success()); + } + + #[test] + fn parse_real_failed_response() { + let response: VideoTaskRawResponse = serde_json::from_str(REAL_FAILED_RESPONSE) + .expect("should parse"); + + assert_eq!(response.status, CometVideoTaskStatus::Failed); + assert_eq!(response.video_url, None); + assert!(response.status.is_terminal()); + assert!(response.status.is_failure()); + } +} diff --git a/crates/api_clients/comet_api_client/src/requests/mod.rs b/crates/api_clients/comet_api_client/src/requests/mod.rs new file mode 100644 index 0000000000..f61434b8c1 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/requests/mod.rs @@ -0,0 +1,4 @@ +pub mod comet_host; +pub mod create_video; +pub mod get_video_task; +pub mod video_task_status; diff --git a/crates/api_clients/comet_api_client/src/requests/video_task_status.rs b/crates/api_clients/comet_api_client/src/requests/video_task_status.rs new file mode 100644 index 0000000000..d293f581b1 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/requests/video_task_status.rs @@ -0,0 +1,132 @@ +use std::fmt; + +use serde::de::{Deserialize, Deserializer}; +use serde::ser::{Serialize, Serializer}; + +/// Lifecycle status of a CometAPI video task. +/// +/// The `Unknown(String)` catch-all future-proofs against CometAPI adding new +/// states — callers should treat unknown states as still-running unless +/// proven otherwise. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CometVideoTaskStatus { + Queued, + InProgress, + Completed, + Failed, + Error, + Unknown(String), +} + +impl CometVideoTaskStatus { + pub fn to_str(&self) -> &str { + match self { + Self::Queued => "queued", + Self::InProgress => "in_progress", + Self::Completed => "completed", + Self::Failed => "failed", + Self::Error => "error", + Self::Unknown(value) => value, + } + } + + pub fn from_str(value: &str) -> Self { + match value { + "queued" => Self::Queued, + "in_progress" => Self::InProgress, + "completed" => Self::Completed, + "failed" => Self::Failed, + "error" => Self::Error, + other => Self::Unknown(other.to_string()), + } + } + + /// Whether the task has reached a terminal state (stop polling). + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Completed | Self::Failed | Self::Error) + } + + pub fn is_success(&self) -> bool { + matches!(self, Self::Completed) + } + + pub fn is_failure(&self) -> bool { + matches!(self, Self::Failed | Self::Error) + } +} + +impl fmt::Display for CometVideoTaskStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_str()) + } +} + +impl<'de> Deserialize<'de> for CometVideoTaskStatus { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + Ok(Self::from_str(&value)) + } +} + +impl Serialize for CometVideoTaskStatus { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.to_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deserialize_known_states() { + let cases = [ + ("\"queued\"", CometVideoTaskStatus::Queued), + ("\"in_progress\"", CometVideoTaskStatus::InProgress), + ("\"completed\"", CometVideoTaskStatus::Completed), + ("\"failed\"", CometVideoTaskStatus::Failed), + ("\"error\"", CometVideoTaskStatus::Error), + ]; + for (json, expected) in cases { + let status: CometVideoTaskStatus = serde_json::from_str(json).expect("should parse"); + assert_eq!(status, expected); + } + } + + #[test] + fn deserialize_unknown_state_is_future_proof() { + let status: CometVideoTaskStatus = serde_json::from_str("\"hyper_queued\"").expect("should parse"); + assert_eq!(status, CometVideoTaskStatus::Unknown("hyper_queued".to_string())); + assert!(!status.is_terminal()); + } + + #[test] + fn round_trip() { + for status in [ + CometVideoTaskStatus::Queued, + CometVideoTaskStatus::InProgress, + CometVideoTaskStatus::Completed, + CometVideoTaskStatus::Failed, + CometVideoTaskStatus::Error, + CometVideoTaskStatus::Unknown("new_state".to_string()), + ] { + let json = serde_json::to_string(&status).expect("should serialize"); + let parsed: CometVideoTaskStatus = serde_json::from_str(&json).expect("should parse"); + assert_eq!(parsed, status); + } + } + + #[test] + fn terminal_states() { + assert!(!CometVideoTaskStatus::Queued.is_terminal()); + assert!(!CometVideoTaskStatus::InProgress.is_terminal()); + assert!(CometVideoTaskStatus::Completed.is_terminal()); + assert!(CometVideoTaskStatus::Failed.is_terminal()); + assert!(CometVideoTaskStatus::Error.is_terminal()); + + assert!(CometVideoTaskStatus::Completed.is_success()); + assert!(!CometVideoTaskStatus::Completed.is_failure()); + assert!(CometVideoTaskStatus::Failed.is_failure()); + assert!(CometVideoTaskStatus::Error.is_failure()); + } +} diff --git a/crates/api_clients/comet_api_client/src/test_utils/mod.rs b/crates/api_clients/comet_api_client/src/test_utils/mod.rs new file mode 100644 index 0000000000..e16f3ce4f2 --- /dev/null +++ b/crates/api_clients/comet_api_client/src/test_utils/mod.rs @@ -0,0 +1,11 @@ +use std::fs::read_to_string; + +use crate::creds::comet_api_key::CometApiKey; + +const API_KEY_PATH: &str = "/Users/bt/Artcraft/credentials/comet_api_key.txt"; + +pub fn load_api_key() -> CometApiKey { + let secret = read_to_string(API_KEY_PATH) + .expect("Failed to read Comet API key file"); + CometApiKey::from_str(&secret) +}