diff --git a/crates/api_clients/openai_sora_client/src/creds/mod.rs b/crates/api_clients/openai_sora_client/src/creds/mod.rs index 1e30ad9d35..1cea80d0bc 100644 --- a/crates/api_clients/openai_sora_client/src/creds/mod.rs +++ b/crates/api_clients/openai_sora_client/src/creds/mod.rs @@ -2,4 +2,5 @@ pub mod sora_cookies; pub mod sora_credential_builder; pub mod sora_credential_set; pub mod sora_jwt_bearer_token; -pub mod sora_sentinel; \ No newline at end of file +pub mod sora_sentinel; +pub mod sora_sentinel_token; \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_credential_builder.rs b/crates/api_clients/openai_sora_client/src/creds/sora_credential_builder.rs index 1b7ed58693..a23cbfc27b 100644 --- a/crates/api_clients/openai_sora_client/src/creds/sora_credential_builder.rs +++ b/crates/api_clients/openai_sora_client/src/creds/sora_credential_builder.rs @@ -2,13 +2,20 @@ use crate::creds::sora_cookies::SoraCookies; use crate::creds::sora_credential_set::SoraCredentialSet; use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; use crate::creds::sora_sentinel::SoraSentinel; +use crate::creds::sora_sentinel_token::SoraSentinelToken; use crate::error::sora_client_error::SoraClientError; use crate::error::sora_error::SoraError; pub struct SoraCredentialBuilder { cookies: Option, + jwt_bearer_token: Option, + + #[deprecated(note="use sora_sentinel_token instead")] sora_sentinel: Option, + + /// This is the newer sentinel token. Gradually phase out the older one. + sora_sentinel_token: Option, } impl SoraCredentialBuilder { @@ -17,6 +24,7 @@ impl SoraCredentialBuilder { cookies: None, jwt_bearer_token: None, sora_sentinel: None, + sora_sentinel_token: None, } } @@ -37,6 +45,11 @@ impl SoraCredentialBuilder { self } + pub fn with_sora_sentinel_token(mut self, sentinel_token: &SoraSentinelToken) -> Self { + self.sora_sentinel_token = Some(sentinel_token.clone()); + self + } + // Mutable methods pub fn set_cookies(&mut self, cookies: &str) { @@ -50,6 +63,10 @@ impl SoraCredentialBuilder { pub fn set_sora_sentinel(&mut self, sentinel: &str) { self.sora_sentinel = Some(sentinel.to_string()); } + + pub fn set_sora_sentinel_token(&mut self, sentinel_token: &SoraSentinelToken) { + self.sora_sentinel_token = Some(sentinel_token.clone()); + } pub fn build(self) -> Result { let cookies = match self.cookies { @@ -67,10 +84,16 @@ impl SoraCredentialBuilder { None => None, }; + let sora_sentinel_token = match self.sora_sentinel_token { + Some(s) => Some(s), + None => None, + }; + Ok(SoraCredentialSet { cookies, jwt_bearer_token, sora_sentinel, + sora_sentinel_token, }) } } diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_credential_set.rs b/crates/api_clients/openai_sora_client/src/creds/sora_credential_set.rs index 3abd7fc730..5f885e6444 100644 --- a/crates/api_clients/openai_sora_client/src/creds/sora_credential_set.rs +++ b/crates/api_clients/openai_sora_client/src/creds/sora_credential_set.rs @@ -2,24 +2,32 @@ use crate::creds::sora_cookies::SoraCookies; use crate::creds::sora_credential_builder::SoraCredentialBuilder; use crate::creds::sora_jwt_bearer_token::SoraJwtBearerToken; use crate::creds::sora_sentinel::SoraSentinel; +use crate::creds::sora_sentinel_token::SoraSentinelToken; #[derive(Clone)] pub struct SoraCredentialSet { pub cookies: SoraCookies, pub jwt_bearer_token: Option, + + #[deprecated(note="use sora_sentinel_token instead")] pub sora_sentinel: Option, + + // NB: This is the newer sentinel token. Gradually phase out the older one. + pub sora_sentinel_token: Option, } impl SoraCredentialSet { pub fn initialize( cookies: SoraCookies, bearer: Option, - sentinel: Option + sentinel: Option, + sentinel_token: Option, ) -> Self { Self { cookies, jwt_bearer_token: bearer, sora_sentinel: sentinel, + sora_sentinel_token: sentinel_token, } } @@ -28,6 +36,7 @@ impl SoraCredentialSet { cookies, jwt_bearer_token: None, sora_sentinel: None, + sora_sentinel_token: None, } } @@ -46,6 +55,9 @@ impl SoraCredentialSet { if let Some(sentinel) = &self.sora_sentinel { builder = builder.with_sora_sentinel(sentinel.as_str()); } + if let Some(sentinel_token) = &self.sora_sentinel_token { + builder = builder.with_sora_sentinel_token(sentinel_token); + } builder } } diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_sentinel.rs b/crates/api_clients/openai_sora_client/src/creds/sora_sentinel.rs index 86fa0fe204..70aa657293 100644 --- a/crates/api_clients/openai_sora_client/src/creds/sora_sentinel.rs +++ b/crates/api_clients/openai_sora_client/src/creds/sora_sentinel.rs @@ -1,6 +1,7 @@ /// Sora Sentinel tokens can be generated without cookies or JWTs, and are needed for some API calls. #[derive(Clone)] +#[deprecated(note="use SoraSentinelToken instead")] pub struct SoraSentinel { sentinel: String, @@ -13,14 +14,17 @@ impl SoraSentinel { SoraSentinel { sentinel } } + #[deprecated(note="use SoraSentinelToken instead")] pub fn get_sentinel(&self) -> &str { &self.sentinel } + #[deprecated(note="use SoraSentinelToken instead")] pub fn as_str(&self) -> &str { &self.sentinel } + #[deprecated(note="use SoraSentinelToken instead")] pub fn as_bytes(&self) -> &[u8] { self.sentinel.as_bytes() } diff --git a/crates/api_clients/openai_sora_client/src/creds/sora_sentinel_token.rs b/crates/api_clients/openai_sora_client/src/creds/sora_sentinel_token.rs new file mode 100644 index 0000000000..0c2fdc2e9d --- /dev/null +++ b/crates/api_clients/openai_sora_client/src/creds/sora_sentinel_token.rs @@ -0,0 +1,94 @@ +use crate::error::sora_client_error::SoraClientError; +use chrono::{DateTime, TimeDelta, Utc}; +use serde_derive::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SoraSentinelToken { + /// The token sent over the wire. + /// (The other fields in this struct are metadata.) + pub token: RawSoraSentinelToken, + + /// User agent used to get the token. + pub browser_user_agent: String, + + /// Clientside time when it was generated (client-side). + pub generated_at: DateTime, + + /// Expiration time (reported by the server) + /// If the server doesn't tell us, we'll fill it in with our best guess. + pub expires_at: Option>, + + /// Seconds of validity (reported by the server) + /// If the server doesn't tell us, we'll fill it in with our best guess. + pub expires_in_seconds: Option, +} + +/// This is what is sent over the wire in requests. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RawSoraSentinelToken { + /// "Proof of Work" token (we generated this clientside) + #[serde(rename = "p")] + pub p: String, + + /// Random UUID (we generated this clientside) + #[serde(rename = "id")] + pub id: String, + + /// Frontend UI flow (this is the website flow used) + #[serde(rename = "flow")] + pub flow: String, + + /// Turnstile dx from the response. + /// response["turnstile"]["dx"] + #[serde(rename = "t")] + pub t: String, + + /// Token from the response. + /// response["token"] + #[serde(rename = "c")] + pub c: String, +} + +impl SoraSentinelToken { + pub fn is_expired(&self) -> bool { + // NB(1): We're not trusting the server timestamp for now. + // NB(2): We're not self-expiring for now. + let now = Utc::now(); + + if let Some(expires_in_seconds) = self.expires_in_seconds { + let maybe_expires_at = now + .checked_add_signed(TimeDelta::seconds(expires_in_seconds as i64)); + + if let Some(expires_at) = maybe_expires_at { + if now >= expires_at { + return true; + } + } + } + + false + } + + /// Convert to JSON, which is the raw over-the-wire format Sora consumes. + pub fn to_request_header_json(&self) -> Result { + self.token.to_request_header_json() + } + + pub fn to_persistent_storage_json(&self) -> Result { + serde_json::to_string(self) + .map_err(|err| SoraClientError::CouldNotSerializeSentinelTokenStore(err)) + } + + pub fn from_persistent_storage_json(json: &str) -> Result { + serde_json::from_str(json) + .map_err(|err| SoraClientError::CouldNotDeserializeSentinelTokenStore(err)) + } +} + +impl RawSoraSentinelToken { + /// Convert to JSON, which is the raw over-the-wire format Sora consumes. + pub fn to_request_header_json(&self) -> Result { + serde_json::to_string(self) + .map_err(|err| SoraClientError::CouldNotSerializeSentinelToken(err)) + } +} diff --git a/crates/api_clients/openai_sora_client/src/error/sora_client_error.rs b/crates/api_clients/openai_sora_client/src/error/sora_client_error.rs index 8fe4bb710a..f5680cbe0c 100644 --- a/crates/api_clients/openai_sora_client/src/error/sora_client_error.rs +++ b/crates/api_clients/openai_sora_client/src/error/sora_client_error.rs @@ -35,6 +35,12 @@ pub enum SoraClientError { /// Error serializing the sentinel token to JSON CouldNotSerializeSentinelToken(serde_json::Error), + + /// Error serializing the sentinel store token to JSON (typically for persistent storage). + CouldNotSerializeSentinelTokenStore(serde_json::Error), + + /// Error deserializing the sentinel token from JSON (typically for persistent storage). + CouldNotDeserializeSentinelTokenStore(serde_json::Error), } impl Error for SoraClientError {} @@ -52,6 +58,8 @@ impl Display for SoraClientError { Self::UrlParseError(err) => write!(f, "URL parse error: {}", err), Self::WreqClientError(err) => write!(f, "Wreq client error (during client creation): {}", err), Self::CouldNotSerializeSentinelToken(err) => write!(f, "Could not serialize sentinel token to JSON: {}", err), + Self::CouldNotSerializeSentinelTokenStore(err) => write!(f, "Could not serialize sentinel token store to JSON: {}", err), + Self::CouldNotDeserializeSentinelTokenStore(err) => write!(f, "Could not deserialize sentinel token store from JSON: {}", err), } } } diff --git a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/generate_sentinel_token_2.rs b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/generate_sentinel_token_2.rs index b0fc90d78a..c3006bb3a7 100644 --- a/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/generate_sentinel_token_2.rs +++ b/crates/api_clients/openai_sora_client/src/requests/auth_sentinel_2/generate_sentinel_token_2.rs @@ -1,11 +1,12 @@ use crate::constants::user_agent::CLIENT_USER_AGENT; +use crate::creds::sora_sentinel_token::{RawSoraSentinelToken, SoraSentinelToken}; use crate::error::sora_error::SoraError; use crate::error::sora_generic_api_error::SoraGenericApiError; use crate::error::sora_specific_api_error::SoraSpecificApiError; use crate::requests::auth_sentinel::request::GenerateSentinelRefreshRequest; use crate::requests::auth_sentinel_2::request::SentinelRequest; use crate::requests::auth_sentinel_2::response::SentinelResponse; -use crate::requests::auth_sentinel_2::sentinel_token::{SentinelResponsePieces, SentinelToken}; +use crate::requests::auth_sentinel_2::sentinel_token::SentinelResponsePieces; use crate::utils_internal::classify_general_http_error::classify_general_http_error; use errors::AnyhowResult; use idempotency::uuid::generate_random_uuid; @@ -21,7 +22,7 @@ const SORA_SENTINEL_ENDPOINT: &str = "https://chatgpt.com/backend-api/sentinel/r /// This generates a Sentinel Token that works with Sora 1.0 and Sora 2.0 consumer products. -pub async fn generate_sentinel_token_2() -> Result { +pub async fn generate_sentinel_token_2() -> Result { let (_request, base64_request) = GenerateSentinelRefreshRequest::new().with_fourth_and_tenth(); let request = SentinelRequest::new(base64_request); @@ -72,49 +73,75 @@ pub async fn generate_sentinel_token_2() -> Result { SoraGenericApiError::SerdeResponseParseErrorWithBody(err, text_body.to_string()) })?; - let response_pieces = sentinel_response_into_pieces(response, text_body)?; - let sentinel_token = SentinelToken::from_server_request(&request, &response_pieces); + let sentinel_token = into_sora_sentinel_token(request, response, text_body)?; Ok(sentinel_token) } -fn sentinel_response_into_pieces(response: SentinelResponse, raw_body: &str) -> Result { + +fn into_sora_sentinel_token(request: SentinelRequest, response: SentinelResponse, raw_body: &str) -> Result { + let maybe_token = response.token; + let maybe_turnstile = response.turnstile .map(|turn| turn.dx) .flatten(); let mut missing_token = false; - let mut missing_turnstile = false; + let mut missing_turnstile = false; - match (response.token, maybe_turnstile) { - (None, None) => { - missing_token = true; - missing_turnstile = true; + let (token, turnstile) = { + let maybe_items = match (maybe_token, maybe_turnstile) { + (Some(token), Some(turnstile)) => { + Some((token, turnstile)) + } + (None, None) => { + missing_token = true; + missing_turnstile = true; + None + } + (None, Some(_turnstile)) => { + missing_token = true; + None + } + (Some(_token), None) => { + missing_turnstile = true; + None + } + }; + match maybe_items { + Some((token, turnstile)) => (token, turnstile), + _ => return Err(SoraSpecificApiError::SentinelResponseIsMissingFields { + missing_token, + missing_turnstile, + raw_response: raw_body.to_string(), + }), } - (None, Some(_turnstile)) => { - missing_token = true; - } - (Some(_token), None) => { - missing_turnstile = true; - } - (Some(token), Some(turnstile)) => { - return Ok(SentinelResponsePieces { - token, - turnstile_dx: turnstile, - }); - } - } + }; - Err(SoraSpecificApiError::SentinelResponseIsMissingFields { - missing_token, - missing_turnstile, - raw_response: raw_body.to_string(), - }) + let sentinel_raw = RawSoraSentinelToken { + p: request.p, + id: request.id, + flow: request.flow, + t: turnstile, + c: token, + }; + + let sentinel = SoraSentinelToken { + token: sentinel_raw, + browser_user_agent: CLIENT_USER_AGENT.to_string(), + generated_at: Default::default(), // TODO + expires_at: None, // TODO + expires_in_seconds: None, // TODO + }; + + Ok(sentinel) } + #[cfg(test)] mod tests { + use std::fs::write; use super::*; #[tokio::test] @@ -125,4 +152,19 @@ mod tests { assert_eq!(1, 2); Ok(()) } + + #[tokio::test] + #[ignore] // Only manually trigger this + async fn write_token_to_disk() -> AnyhowResult<()> { + let filename = "/Users/bt/Artcraft/credentials/sora_sentinel_token_store.json"; + let token = generate_sentinel_token_2().await?; + + let json = token.to_persistent_storage_json()?; + + write(filename, &json)?; + + assert_eq!(1, 2); + Ok(()) + } + } \ No newline at end of file diff --git a/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/generate_sora2_video.rs b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/generate_sora2_video.rs index e157db2ed3..e8c0f8f7bd 100644 --- a/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/generate_sora2_video.rs +++ b/crates/api_clients/openai_sora_client/src/requests/generate_sora2_video/generate_sora2_video.rs @@ -3,6 +3,7 @@ use crate::creds::sora_credential_set::SoraCredentialSet; use crate::error::sora_client_error::SoraClientError; use crate::error::sora_error::SoraError; use crate::error::sora_generic_api_error::SoraGenericApiError; +use crate::requests::auth_sentinel_2::generate_sentinel_token_2::generate_sentinel_token_2; use crate::requests::common::task_id::TaskId; use crate::requests::generate_sora2_video::http_request::{HttpCreateRequest, InpaintItem}; use crate::requests::generate_sora2_video::http_response::HttpCreateResponse; @@ -13,7 +14,6 @@ use std::io::Write; use std::time::Duration; use wreq::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_TYPE, COOKIE, ORIGIN, REFERER, USER_AGENT}; use wreq::Client; -use crate::requests::auth_sentinel_2::generate_sentinel_token_2::generate_sentinel_token_2; const GENERATE_SORA_2_VIDEO_URL: &str = "https://sora.chatgpt.com/backend/nf/create"; @@ -47,21 +47,18 @@ pub (crate) async fn generate_sora2_video( .ok_or(SoraClientError::NoBearerTokenForRequest)? .to_authorization_header_value(); - let sentinel_token = generate_sentinel_token_2().await?; - - let sentinel_token = sentinel_token.to_json()?; - - //let sentinel_token = args.credentials.sora_sentinel.as_ref() - // .map(|sentinel| sentinel.get_sentinel().to_string()) - // .ok_or(SoraClientError::NoSentinelTokenForRequest)?; + let sentinel_token = args.credentials.sora_sentinel_token + .as_ref() + .ok_or(SoraClientError::NoSentinelTokenForRequest)?; + + let sentinel_token = sentinel_token.to_request_header_json()?; let cookie = args.credentials.cookies.to_string(); // TODO: Make the sec-* headers match the user agent and platform - // TODO: No sentinel? // TODO: device id //-H 'oai-device-id: 7c216860-5b73-4e63-983f-142dbcae1809' \ - let mut http_request = client.post(GENERATE_SORA_2_VIDEO_URL) + let mut request_builder = client.post(GENERATE_SORA_2_VIDEO_URL) .header(ACCEPT, "*/*") .header("priority", "u=1, i") .header(REFERER, "https://sora.chatgpt.com/explore") @@ -80,7 +77,7 @@ pub (crate) async fn generate_sora2_video( .header("sec-fetch-site", "same-origin"); if let Some(timeout) = args.request_timeout { - http_request = http_request.timeout(timeout); + request_builder = request_builder.timeout(timeout); } let orientation = match args.orientation { @@ -119,15 +116,13 @@ pub (crate) async fn generate_sora2_video( storyboard_id: None, }; - let http_request = http_request.json(&request_body).build() + let http_request = request_builder.json(&request_body) + .build() .map_err(|err| { error!("Error building Sora image generation HTTP request: {:?}", err); SoraClientError::WreqClientError(err) })?; - println!("send request..."); - std::io::stdout().flush().unwrap(); - let response = client.execute(http_request) .await .map_err(|err| { @@ -135,8 +130,6 @@ pub (crate) async fn generate_sora2_video( SoraClientError::WreqClientError(err) })?; - println!("read status..."); - std::io::stdout().flush().unwrap(); let status = response.status(); let response_body = &response.text().await @@ -170,7 +163,7 @@ mod tests { let creds = get_test_credentials()?; let request = GenerateSora2VideoArgs { //prompt: "A cute corgi wearing glasses, sitting on a picnic blanket and reading a book, digital art", - prompt: "A dog eating waffles in a Waffle House", + prompt: "A dog eating pancakes in an iHop", credentials: &creds, request_timeout: None, orientation: Orientation::Landscape, diff --git a/crates/api_clients/openai_sora_client/src/test_utils/get_test_credentials.rs b/crates/api_clients/openai_sora_client/src/test_utils/get_test_credentials.rs index 9238a6dccd..3550a736c8 100644 --- a/crates/api_clients/openai_sora_client/src/test_utils/get_test_credentials.rs +++ b/crates/api_clients/openai_sora_client/src/test_utils/get_test_credentials.rs @@ -1,5 +1,6 @@ use crate::creds::sora_credential_builder::SoraCredentialBuilder; use crate::creds::sora_credential_set::SoraCredentialSet; +use crate::creds::sora_sentinel_token::SoraSentinelToken; use errors::AnyhowResult; use std::fs::read_to_string; @@ -8,7 +9,12 @@ pub fn get_test_credentials() -> AnyhowResult { // TODO: This should work on other folks' machines when they exist // This is so brittle... let sentinel = read_to_string("/Users/bt/Artcraft/credentials/sora_sentinel.txt")?; - let sentinel = sentinel.trim().to_string(); + + let sentinel_token = { + let sentinel_json = read_to_string("/Users/bt/Artcraft/credentials/sora_sentinel_token_store.json")?; + let sentinel_json = sentinel.trim().to_string(); + SoraSentinelToken::from_persistent_storage_json(&sentinel_json)? + }; let cookie = read_to_string("/Users/bt/Artcraft/credentials/sora_cookies.txt")?; let cookie = cookie.trim().to_string(); @@ -20,7 +26,8 @@ pub fn get_test_credentials() -> AnyhowResult { .with_cookies(&cookie) .with_jwt_bearer_token(&bearer) .with_sora_sentinel(&sentinel) + .with_sora_sentinel_token(&sentinel_token) .build()?; Ok(creds) -} \ No newline at end of file +}