diff --git a/README.md b/README.md index 2e052ad2f9..7e8b5fe8bb 100755 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | Command Code | Cookies | Monthly Credits, Purchased Credits | | Crof | API Key | Credits, Request Quota | | StepFun | Oasis Token | 5h, Weekly, Token refresh | -| Venice | API Key | USD / DIEM Balance | +| Venice | API Key / Web session | USD / DIEM Balance, Bundled credits (web session expires in about 60 s) | | OpenAI | Admin API / API Key | Usage, Requests, Project-scoped cost, Credit Balance | | Grok | Cookies / auth.json | Billing | | Helmcode (also NaN Builders) | Browser cookies / manual Cookie header | Per-model token quotas, reset windows, Helmcode prepaid balance | diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 237f7dab3d..c8eced66cc 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -45,6 +45,20 @@ Settings → **Providers** → provider detail → choose browser → Import. Manual cookie header paste is the fallback (required under WSL for Chromium DPAPI). Details: [COOKIES.md](./COOKIES.md). +### Venice web session + +Venice normally uses an API key (USD / DIEM balance). The optional web mode +reads the signed-in `venice.ai` Clerk session instead and reports the +bundled-credit quota from the session token. + +- Web (automatic) mode needs a signed-in `venice.ai` tab in a supported + browser, because the session token comes from the `__session` cookie. +- Clerk sessions last only about 60 seconds, so Web mode does not refresh + unattended. When the session has expired, open `venice.ai` in the browser + again and refresh. +- Manual mode needs a freshly pasted Cookie header from a signed-in + `venice.ai` request; an old header fails with the expired-session message. + ### Replicate billing Replicate uses the signed-in `replicate.com` session cookie for its billing diff --git a/rust/src/providers/venice/mod.rs b/rust/src/providers/venice/mod.rs index 5b1034441f..da5e38c9fb 100644 --- a/rust/src/providers/venice/mod.rs +++ b/rust/src/providers/venice/mod.rs @@ -18,7 +18,10 @@ const VENICE_BALANCE_URL: &str = "https://api.venice.ai/api/v1/billing/balance"; const VENICE_SESSION_URL: &str = "https://outerface.venice.ai/api/user/session"; const VENICE_CREDENTIAL_TARGET: &str = "codexbar-venice"; const VENICE_SESSION_COOKIE: &str = "__venice-auth.session-token"; -const VENICE_COOKIE_DOMAINS: &[&str] = &["venice.ai", "outerface.venice.ai"]; +const VENICE_COOKIE_DOMAIN: &str = "venice.ai"; +const VENICE_CLERK_SESSION_COOKIE: &str = "__session"; +const VENICE_MISSING_CREDENTIALS_MESSAGE: &str = "Venice session cookie not found (__session, __session_, or __venice-auth.session-token). Open a signed-in venice.ai tab and retry, or paste a fresh Cookie header."; +const VENICE_INVALID_SESSION_MESSAGE: &str = "Venice browser session is invalid or expired. Keep a signed-in venice.ai tab active and retry; Clerk sessions last about 60 seconds. In Manual mode, paste a fresh Cookie header."; const VENICE_EXPIRATION_SKEW_SECS: i64 = 60; const MAX_VENICE_COOKIE_HEADER_LEN: usize = 1_048_576; const MAX_VENICE_COOKIE_VALUE_LEN: usize = 16_384; @@ -39,9 +42,31 @@ struct VeniceBalances { usd: Option, } -#[derive(Debug, Deserialize)] -struct VeniceSessionResponse { - token: String, +/// Why a `/api/auth/session` reply carried no usable session token. +#[derive(Debug, PartialEq, Eq)] +enum SessionTokenError { + /// The reply parsed but has a missing, null, non-string or blank `token`. + /// Upstream treats this as invalid credentials (an expired Clerk session). + Invalid, + /// The body is not a JSON object. + Malformed(String), +} + +/// Extracts the session token from a `/api/auth/session` body the way upstream +/// `VeniceWebUsageFetcher.snapshot(fromSessionData:)` does: a JSON object whose +/// `token` is a non-blank string. +fn session_token_from_body(body: &[u8]) -> Result { + let value: Value = serde_json::from_slice(body) + .map_err(|error| SessionTokenError::Malformed(error.to_string()))?; + let Some(object) = value.as_object() else { + return Err(SessionTokenError::Malformed( + "expected a JSON object".to_string(), + )); + }; + match object.get("token").and_then(Value::as_str) { + Some(token) if !token.trim().is_empty() => Ok(token.to_string()), + _ => Err(SessionTokenError::Invalid), + } } pub struct VeniceProvider { @@ -108,17 +133,18 @@ impl VeniceProvider { &self, manual_cookie_header: Option<&str>, ) -> Result { - let raw_cookie_header = match manual_cookie_header { - Some(header) => header.to_string(), - None => crate::providers::browser_cookie_header(VENICE_COOKIE_DOMAINS)?, - }; - let cookie_header = - session_cookie_header(&raw_cookie_header).ok_or(ProviderError::NoCookies)?; + let credential = match manual_cookie_header { + Some(header) => session_credential_from_header(header), + None => match crate::providers::browser_cookies_for_domain(VENICE_COOKIE_DOMAIN) { + Ok(cookies) => session_credential_from_browser_cookies(&cookies), + Err(ProviderError::NoCookies) => None, + Err(error) => return Err(error), + }, + } + .ok_or_else(|| ProviderError::Other(VENICE_MISSING_CREDENTIALS_MESSAGE.into()))?; - let response = self - .client - .get(VENICE_SESSION_URL) - .header("Cookie", cookie_header) + let response = credential + .apply(self.client.get(VENICE_SESSION_URL)) .header("Accept", "application/json") .send() .await?; @@ -126,7 +152,7 @@ impl VeniceProvider { if response.status() == reqwest::StatusCode::UNAUTHORIZED || response.status() == reqwest::StatusCode::FORBIDDEN { - return Err(ProviderError::AuthRequired); + return Err(invalid_session_error()); } if !response.status().is_success() { return Err(ProviderError::Other(format!( @@ -135,14 +161,17 @@ impl VeniceProvider { ))); } - let session: VeniceSessionResponse = response.json().await.map_err(|e| { - ProviderError::Parse(format!("Failed to parse Venice web session: {e}")) - })?; - if session.token.trim().is_empty() { - return Err(ProviderError::AuthRequired); - } - let token = session.token.as_str(); - let claims = crate::codex_accounts::api::jwt_payload(token) + let body = response.bytes().await?; + let token = match session_token_from_body(&body) { + Ok(token) => token, + Err(SessionTokenError::Invalid) => return Err(invalid_session_error()), + Err(SessionTokenError::Malformed(detail)) => { + return Err(ProviderError::Parse(format!( + "Failed to parse Venice web session: {detail}" + ))); + } + }; + let claims = crate::codex_accounts::api::jwt_payload(&token) .ok_or_else(|| ProviderError::Parse("Venice session token is not a JWT".into()))?; snapshot_from_web_claims(&claims, Utc::now()) } @@ -233,21 +262,98 @@ impl Provider for VeniceProvider { fn owns_browser_cookie_resolution(&self) -> bool { true } + + /// Venice web credential failures carry upstream's recovery text in + /// `ProviderError::Other`; keep them classified as sign-in gates. + fn error_state_kind(&self, error: &ProviderError) -> crate::core::ProviderStateKind { + match error { + ProviderError::Other(message) if message == VENICE_MISSING_CREDENTIALS_MESSAGE => { + crate::core::ProviderStateKind::NeedsAuthentication + } + ProviderError::Other(message) if message == VENICE_INVALID_SESSION_MESSAGE => { + crate::core::ProviderStateKind::ExpiredSession + } + _ => error.state_kind(), + } + } +} + +fn invalid_session_error() -> ProviderError { + ProviderError::Other(VENICE_INVALID_SESSION_MESSAGE.into()) +} + +/// Credential accepted by the Venice session endpoint. +#[derive(Debug, PartialEq, Eq)] +enum VeniceSessionCredential { + /// Legacy `__venice-auth.session-token` value, sent as a Cookie header. + Legacy(String), + /// Clerk `__session` / `__session_` value, sent as a Bearer token + /// with no Cookie header. + Clerk(String), +} + +impl VeniceSessionCredential { + fn apply(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match self { + Self::Legacy(value) => { + request.header("Cookie", format!("{VENICE_SESSION_COOKIE}={value}")) + } + Self::Clerk(value) => request.bearer_auth(value), + } + } +} + +fn is_clerk_session_cookie_name(name: &str) -> bool { + name == VENICE_CLERK_SESSION_COOKIE + || name + .strip_prefix("__session_") + .is_some_and(|suffix| !suffix.is_empty()) +} + +/// Browser cookies are trusted only from the exact `venice.ai` host, so +/// `clerk.venice.ai` cookies such as `__client` are never used. The shared +/// extractor also returns subdomain cookies, hence the filter here. +fn session_credential_from_browser_cookies( + cookies: &[crate::browser::cookies::Cookie], +) -> Option { + session_credential( + cookies + .iter() + .filter(|cookie| { + cookie + .domain + .trim() + .trim_matches('.') + .eq_ignore_ascii_case(VENICE_COOKIE_DOMAIN) + }) + .map(|cookie| (cookie.name.as_str(), cookie.value.as_str())), + ) } -fn session_cookie_header(raw: &str) -> Option { +fn session_credential_from_header(raw: &str) -> Option { if raw.len() > MAX_VENICE_COOKIE_HEADER_LEN { return None; } + // A header pasted from DevTools often keeps its "Cookie:" prefix. + let raw = raw.trim(); + let raw = match raw.get(.."cookie:".len()) { + Some(prefix) if prefix.eq_ignore_ascii_case("cookie:") => raw["cookie:".len()..].trim(), + _ => raw, + }; + session_credential(raw.split(';').filter_map(|part| part.split_once('='))) +} +/// The legacy cookie (exact, then contiguous chunks) wins over Clerk; among +/// Clerk cookies the unsuffixed `__session` wins over the first suffixed one. +fn session_credential<'a>( + pairs: impl Iterator, +) -> Option { let mut exact = None; + let mut clerk: Option<(&str, &str)> = None; let mut chunks = BTreeMap::new(); let chunk_prefix = format!("{VENICE_SESSION_COOKIE}."); - for part in raw.split(';') { - let Some((raw_name, raw_value)) = part.split_once('=') else { - continue; - }; + for (raw_name, raw_value) in pairs { let name = raw_name.trim(); let value = raw_value.trim(); if value.is_empty() @@ -256,6 +362,12 @@ fn session_cookie_header(raw: &str) -> Option { { continue; } + if is_clerk_session_cookie_name(name) { + if clerk.is_none() || name == VENICE_CLERK_SESSION_COOKIE { + clerk = Some((name, value)); + } + continue; + } if name == VENICE_SESSION_COOKIE { if exact.is_some() { return None; @@ -276,17 +388,16 @@ fn session_cookie_header(raw: &str) -> Option { } if let Some(value) = exact { - return Some(format!("{VENICE_SESSION_COOKIE}={value}")); + return Some(VeniceSessionCredential::Legacy(value)); } - if chunks.is_empty() || chunks.keys().max() != Some(&(chunks.len() - 1)) { - // Chunked cookies are contiguous 0..len-1 by construction; a gap or a - // tail that starts above 0 means a partial or forged set, so the - // session token cannot be reassembled safely. - return None; + // Chunked cookies are contiguous 0..len-1 by construction; a gap or a + // tail that starts above 0 means a partial or forged set, so the session + // token cannot be reassembled safely and a Clerk cookie is used instead. + if !chunks.is_empty() && chunks.keys().max() == Some(&(chunks.len() - 1)) { + let values: Vec = chunks.into_values().collect(); + return Some(VeniceSessionCredential::Legacy(values.concat())); } - - let values: Vec = chunks.into_values().collect(); - Some(format!("{VENICE_SESSION_COOKIE}={}", values.concat())) + clerk.map(|(_, value)| VeniceSessionCredential::Clerk(value.to_string())) } fn snapshot_from_web_claims( @@ -294,9 +405,9 @@ fn snapshot_from_web_claims( now: DateTime, ) -> Result { let expiration = - epoch_value_to_datetime(claims.get("exp")).ok_or_else(|| ProviderError::AuthRequired)?; + epoch_value_to_datetime(claims.get("exp")).ok_or_else(invalid_session_error)?; if expiration < now - chrono::Duration::seconds(VENICE_EXPIRATION_SKEW_SECS) { - return Err(ProviderError::AuthRequired); + return Err(invalid_session_error()); } if claims @@ -475,45 +586,225 @@ mod tests { assert_eq!(snapshot.primary.used_percent, 75.0); } + fn legacy(value: &str) -> Option { + Some(VeniceSessionCredential::Legacy(value.to_string())) + } + + fn clerk(value: &str) -> Option { + Some(VeniceSessionCredential::Clerk(value.to_string())) + } + + fn browser_cookie(name: &str, domain: &str) -> crate::browser::cookies::Cookie { + crate::browser::cookies::Cookie { + name: name.to_string(), + value: "synthetic-session".to_string(), + domain: domain.to_string(), + path: "/".to_string(), + expires: None, + is_secure: true, + is_http_only: false, + } + } + #[test] fn session_cookie_prefers_exact_and_reassembles_contiguous_chunks() { assert_eq!( - session_cookie_header( + session_credential_from_header( "other=x; __venice-auth.session-token.0=ab; __venice-auth.session-token.1=cd" ), - Some("__venice-auth.session-token=abcd".to_string()) + legacy("abcd") ); assert_eq!( - session_cookie_header( + session_credential_from_header( "__venice-auth.session-token.0=ab; __venice-auth.session-token.2=cd" ), None ); assert_eq!( - session_cookie_header( + session_credential_from_header( "__venice-auth.session-token=exact; __venice-auth.session-token.0=chunk" ), - Some("__venice-auth.session-token=exact".to_string()) + legacy("exact") ); assert_eq!( - session_cookie_header("__venice-auth.session-token.0=a\nsecret"), + session_credential_from_header("__venice-auth.session-token.0=a\nsecret"), None ); assert_eq!( - session_cookie_header( + session_credential_from_header( "__venice-auth.session-token=one; __venice-auth.session-token=two" ), None ); assert_eq!( - session_cookie_header("__venice-auth.session-token.not-a-chunk=value"), + session_credential_from_header("__venice-auth.session-token.not-a-chunk=value"), None ); + assert_eq!( + session_credential_from_header("Cookie: __session=pasted; other=x"), + clerk("pasted") + ); + assert_eq!( + session_credential_from_header("cookie: __venice-auth.session-token=legacy"), + legacy("legacy") + ); let oversized = format!( "__venice-auth.session-token={}", "x".repeat(MAX_VENICE_COOKIE_VALUE_LEN + 1) ); - assert_eq!(session_cookie_header(&oversized), None); + assert_eq!(session_credential_from_header(&oversized), None); + } + + #[test] + fn clerk_session_family_is_accepted_and_sent_only_as_bearer() { + for name in ["__session", "__session_synthetic"] { + assert!(is_clerk_session_cookie_name(name)); + let raw = format!( + "__client_uat=123; {name}=synthetic-session; __client=private; clerk_active_synthetic=1" + ); + let credential = session_credential_from_header(&raw).unwrap(); + assert_eq!( + credential, + VeniceSessionCredential::Clerk("synthetic-session".into()) + ); + + let client = Client::new(); + let request = credential + .apply(client.get(VENICE_SESSION_URL)) + .header("Accept", "application/json") + .build() + .unwrap(); + assert_eq!(request.method(), reqwest::Method::GET); + assert_eq!(request.url().as_str(), VENICE_SESSION_URL); + assert_eq!( + request.headers().get("Authorization").unwrap(), + "Bearer synthetic-session" + ); + assert!(request.headers().get("Cookie").is_none()); + } + } + + #[test] + fn legacy_session_is_sent_as_cookie_without_authorization() { + let client = Client::new(); + let request = legacy("legacy") + .unwrap() + .apply(client.get(VENICE_SESSION_URL)) + .build() + .unwrap(); + assert_eq!( + request.headers().get("Cookie").unwrap(), + "__venice-auth.session-token=legacy" + ); + assert!(request.headers().get("Authorization").is_none()); + } + + #[test] + fn legacy_session_retains_priority_over_clerk_including_numbered_chunks() { + assert_eq!( + session_credential_from_header("__session=clerk; __venice-auth.session-token=legacy"), + legacy("legacy") + ); + assert_eq!( + session_credential_from_header( + "__session=clerk; __venice-auth.session-token.1=b; __venice-auth.session-token.0=a" + ), + legacy("ab") + ); + assert_eq!( + session_credential_from_header("__session_synthetic=secondary; __session=primary"), + clerk("primary") + ); + assert_eq!( + session_credential_from_header("__session=primary; __session_synthetic=secondary"), + clerk("primary") + ); + assert_eq!( + session_credential_from_header("__session_a=first; __session_b=second"), + clerk("first") + ); + // A repeated `__session` keeps the last value, as upstream does. + assert_eq!( + session_credential_from_header("__session=stale; __session=fresh"), + clerk("fresh") + ); + // A partial legacy chunk set cannot be reassembled, so Clerk is used. + assert_eq!( + session_credential_from_header("__venice-auth.session-token.1=b; __session=clerk"), + clerk("clerk") + ); + } + + #[test] + fn browser_session_cookies_are_restricted_to_the_exact_venice_site() { + for (domain, accepted) in [ + ("venice.ai", true), + (".venice.ai", true), + (".Venice.AI", true), + ("clerk.venice.ai", false), + (".clerk.venice.ai", false), + ("outerface.venice.ai", false), + ("notvenice.ai", false), + ] { + let cookies = [browser_cookie("__session", domain)]; + assert_eq!( + session_credential_from_browser_cookies(&cookies), + accepted.then(|| VeniceSessionCredential::Clerk("synthetic-session".into())), + "{domain}" + ); + } + let cookies = [ + browser_cookie("__client", "clerk.venice.ai"), + browser_cookie("__session", "clerk.venice.ai"), + browser_cookie("__venice-auth.session-token", "venice.ai"), + ]; + assert_eq!( + session_credential_from_browser_cookies(&cookies), + legacy("synthetic-session") + ); + } + + #[test] + fn non_session_clerk_and_authjs_cookies_cannot_authenticate() { + for name in [ + "__client", + "__client_uat", + "__client_uat_synthetic", + "clerk_active_synthetic", + "__session_", + "__sessionevil", + "__Host-authjs.csrf-token", + "__Secure-authjs.callback-url", + ] { + assert!(!is_clerk_session_cookie_name(name), "{name}"); + assert_eq!( + session_credential_from_header(&format!("{name}=synthetic")), + None, + "{name}" + ); + } + } + + #[test] + fn recovery_messages_explain_active_tab_and_missing_cookie_names() { + let missing = VENICE_MISSING_CREDENTIALS_MESSAGE; + assert!(missing.contains("__session")); + assert!(missing.contains("__venice-auth.session-token")); + assert!(VENICE_INVALID_SESSION_MESSAGE.contains("tab")); + + let provider = VeniceProvider::new(); + assert_eq!( + provider.error_state_kind(&ProviderError::Other(missing.into())), + crate::core::ProviderStateKind::NeedsAuthentication + ); + assert_eq!( + provider.error_state_kind(&invalid_session_error()), + crate::core::ProviderStateKind::ExpiredSession + ); + assert_eq!( + provider.error_state_kind(&ProviderError::Other("other".into())), + crate::core::ProviderStateKind::Unknown + ); } #[test] @@ -554,6 +845,45 @@ mod tests { ); } + #[test] + fn session_body_without_a_usable_token_is_invalid_credentials() { + for body in [ + r#"{}"#, + r#"{"token":null}"#, + r#"{"token":""}"#, + r#"{"token":" "}"#, + r#"{"token":42}"#, + r#"{"token":{"jwt":"x"}}"#, + ] { + assert_eq!( + session_token_from_body(body.as_bytes()), + Err(SessionTokenError::Invalid), + "{body}" + ); + } + } + + #[test] + fn session_body_that_is_not_an_object_is_malformed() { + for body in ["", "not json", "[]", "\"token\"", "null"] { + assert!( + matches!( + session_token_from_body(body.as_bytes()), + Err(SessionTokenError::Malformed(_)) + ), + "{body}" + ); + } + } + + #[test] + fn session_body_with_a_token_returns_it() { + assert_eq!( + session_token_from_body(br#"{"token":"a.b.c","extra":1}"#), + Ok("a.b.c".to_string()) + ); + } + #[test] fn web_claims_reject_expired_anonymous_and_missing_usage() { let now = DateTime::::from_timestamp(1_900_000_000, 0).unwrap(); @@ -561,7 +891,7 @@ mod tests { expired.insert("exp".into(), Value::from(1_800_000_000)); assert!(matches!( snapshot_from_web_claims(&expired, now), - Err(ProviderError::AuthRequired) + Err(ProviderError::Other(message)) if message == VENICE_INVALID_SESSION_MESSAGE )); let mut anonymous = web_claims();