mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
add referral invites to /usage
This commit is contained in:
Generated
+2
@@ -2058,6 +2058,7 @@ dependencies = [
|
||||
"codex-app-server",
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-chatgpt",
|
||||
"codex-config",
|
||||
"codex-core",
|
||||
"codex-exec-server",
|
||||
@@ -4089,6 +4090,7 @@ dependencies = [
|
||||
"codex-app-server-client",
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-chatgpt",
|
||||
"codex-cli",
|
||||
"codex-cloud-config",
|
||||
"codex-config",
|
||||
|
||||
@@ -16,6 +16,7 @@ workspace = true
|
||||
codex-app-server = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-chatgpt = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
|
||||
@@ -45,6 +45,7 @@ use codex_app_server_protocol::Result as JsonRpcResult;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_chatgpt::referrals::ReferralClient;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::NoopThreadConfigLoader;
|
||||
@@ -436,6 +437,9 @@ enum ClientCommand {
|
||||
error: JSONRPCErrorError,
|
||||
response_tx: oneshot::Sender<IoResult<()>>,
|
||||
},
|
||||
ReferralClient {
|
||||
response_tx: oneshot::Sender<Arc<ReferralClient>>,
|
||||
},
|
||||
Shutdown {
|
||||
response_tx: oneshot::Sender<IoResult<()>>,
|
||||
},
|
||||
@@ -484,6 +488,7 @@ impl InProcessAppServerClient {
|
||||
let channel_capacity = args.channel_capacity.max(1);
|
||||
let mut handle =
|
||||
codex_app_server::in_process::start(args.into_runtime_start_args()).await?;
|
||||
let referral_client = handle.referral_client();
|
||||
let request_sender = handle.sender();
|
||||
let (command_tx, mut command_rx) = mpsc::channel::<ClientCommand>(channel_capacity);
|
||||
let (event_tx, event_rx) = mpsc::channel::<InProcessServerEvent>(channel_capacity);
|
||||
@@ -529,6 +534,9 @@ impl InProcessAppServerClient {
|
||||
let send_result = request_sender.fail_server_request(request_id, error);
|
||||
let _ = response_tx.send(send_result);
|
||||
}
|
||||
Some(ClientCommand::ReferralClient { response_tx }) => {
|
||||
let _ = response_tx.send(Arc::clone(&referral_client));
|
||||
}
|
||||
Some(ClientCommand::Shutdown { response_tx }) => {
|
||||
let shutdown_result = handle.shutdown().await;
|
||||
let _ = response_tx.send(shutdown_result);
|
||||
@@ -757,6 +765,7 @@ impl InProcessAppServerClient {
|
||||
command_tx,
|
||||
event_rx,
|
||||
worker_handle,
|
||||
..
|
||||
} = self;
|
||||
let mut worker_handle = worker_handle;
|
||||
// Drop the caller-facing receiver before asking the worker to shut
|
||||
@@ -829,6 +838,25 @@ impl InProcessAppServerRequestHandle {
|
||||
serde_json::from_value(result)
|
||||
.map_err(|source| TypedRequestError::Deserialize { method, source })
|
||||
}
|
||||
|
||||
pub async fn referral_client(&self) -> IoResult<Arc<ReferralClient>> {
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
self.command_tx
|
||||
.send(ClientCommand::ReferralClient { response_tx })
|
||||
.await
|
||||
.map_err(|_| {
|
||||
IoError::new(
|
||||
ErrorKind::BrokenPipe,
|
||||
"in-process app-server worker channel is closed",
|
||||
)
|
||||
})?;
|
||||
response_rx.await.map_err(|_| {
|
||||
IoError::new(
|
||||
ErrorKind::BrokenPipe,
|
||||
"in-process referral client channel is closed",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl AppServerRequestHandle {
|
||||
@@ -848,6 +876,13 @@ impl AppServerRequestHandle {
|
||||
Self::Remote(handle) => handle.request_typed(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn referral_client(&self) -> IoResult<Option<Arc<ReferralClient>>> {
|
||||
match self {
|
||||
Self::InProcess(handle) => handle.referral_client().await.map(Some),
|
||||
Self::Remote(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppServerClient {
|
||||
|
||||
@@ -45,6 +45,7 @@ use std::io::Error as IoError;
|
||||
use std::io::ErrorKind;
|
||||
use std::io::Result as IoResult;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -77,6 +78,7 @@ use codex_app_server_protocol::Result;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_chatgpt::referrals::ReferralClient;
|
||||
use codex_config::CloudConfigBundleLoader;
|
||||
use codex_config::LoaderOverrides;
|
||||
use codex_config::ThreadConfigLoader;
|
||||
@@ -261,6 +263,7 @@ pub struct InProcessClientHandle {
|
||||
client: InProcessClientSender,
|
||||
event_rx: mpsc::Receiver<InProcessServerEvent>,
|
||||
runtime_handle: tokio::task::JoinHandle<()>,
|
||||
referral_client: Arc<ReferralClient>,
|
||||
#[cfg(test)]
|
||||
_test_codex_home: Option<tempfile::TempDir>,
|
||||
}
|
||||
@@ -342,6 +345,10 @@ impl InProcessClientHandle {
|
||||
pub fn sender(&self) -> InProcessClientSender {
|
||||
self.client.clone()
|
||||
}
|
||||
|
||||
pub fn referral_client(&self) -> Arc<ReferralClient> {
|
||||
Arc::clone(&self.referral_client)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts an in-process app-server runtime and performs initialize handshake.
|
||||
@@ -376,12 +383,18 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
|
||||
let installation_id = resolve_installation_id(&args.config.codex_home).await?;
|
||||
let (client_tx, mut client_rx) = mpsc::channel::<InProcessClientMessage>(channel_capacity);
|
||||
let (event_tx, event_rx) = mpsc::channel::<InProcessServerEvent>(channel_capacity);
|
||||
let referral_auth_manager = Arc::new(OnceLock::new());
|
||||
let referral_client = Arc::new(ReferralClient::new(
|
||||
Arc::clone(&referral_auth_manager),
|
||||
args.config.chatgpt_base_url.clone(),
|
||||
));
|
||||
|
||||
let runtime_handle = tokio::spawn(async move {
|
||||
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(channel_capacity);
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env)
|
||||
.await;
|
||||
let _ = referral_auth_manager.set(Arc::clone(&auth_manager));
|
||||
let analytics_events_client =
|
||||
analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref());
|
||||
let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(
|
||||
@@ -716,11 +729,11 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
|
||||
let _ = done_tx.send(());
|
||||
}
|
||||
});
|
||||
|
||||
Ok(InProcessClientHandle {
|
||||
client: InProcessClientSender { client_tx },
|
||||
event_rx,
|
||||
runtime_handle,
|
||||
referral_client,
|
||||
#[cfg(test)]
|
||||
_test_codex_home: None,
|
||||
})
|
||||
|
||||
@@ -18,12 +18,12 @@ codex-model-provider = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
codex-utils-cli = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
|
||||
[dev-dependencies]
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
pretty_assertions = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod apply_command;
|
||||
mod chatgpt_client;
|
||||
pub mod connectors;
|
||||
pub mod get_task;
|
||||
pub mod referrals;
|
||||
pub mod workspace_settings;
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Client-owned referral requests used by short-lived Codex experiments.
|
||||
|
||||
use anyhow::Context;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
const REFERRAL_KEY: &str = "codex_referral_persistent_invite";
|
||||
const ELIGIBILITY_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const INVITE_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
|
||||
/// A rewarded referral offer and the account that received it.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ReferralOffer {
|
||||
pub description: String,
|
||||
pub rules: Vec<String>,
|
||||
pub grant_action: Option<Value>,
|
||||
pub grant_amount: Option<Value>,
|
||||
pub requires_explicit_confirmation: bool,
|
||||
pub identity: ReferralIdentity,
|
||||
}
|
||||
|
||||
/// The ChatGPT user and workspace that own a referral offer.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ReferralIdentity {
|
||||
pub user_id: String,
|
||||
pub account_id: String,
|
||||
}
|
||||
|
||||
/// Reward status returned after the backend accepted an invite.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ReferralRewardStatus {
|
||||
Included,
|
||||
NotIncluded,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DefiniteReferralInviteRejection {
|
||||
status: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ReferralInvitePreflightFailure {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for DefiniteReferralInviteRejection {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "referral invite was rejected ({})", self.status)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for DefiniteReferralInviteRejection {}
|
||||
|
||||
impl fmt::Display for ReferralInvitePreflightFailure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "referral invite preflight failed: {}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ReferralInvitePreflightFailure {}
|
||||
|
||||
pub fn is_definite_referral_invite_rejection(error: &anyhow::Error) -> bool {
|
||||
error
|
||||
.downcast_ref::<DefiniteReferralInviteRejection>()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn is_referral_invite_preflight_failure(error: &anyhow::Error) -> bool {
|
||||
error
|
||||
.downcast_ref::<ReferralInvitePreflightFailure>()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EligibilityResponse {
|
||||
should_show: bool,
|
||||
#[serde(default)]
|
||||
has_rewards: bool,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
grant_action: Option<Value>,
|
||||
#[serde(default)]
|
||||
grant_amount: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RulesResponse {
|
||||
#[serde(default)]
|
||||
rules: Vec<String>,
|
||||
requires_explicit_confirmation: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct InviteRequest<'a> {
|
||||
referral_key: &'static str,
|
||||
emails: [&'a str; 1],
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InviteResponse {
|
||||
#[serde(default)]
|
||||
has_rewards: Option<bool>,
|
||||
}
|
||||
|
||||
/// HTTP client for the temporary client-owned referral flow.
|
||||
pub struct ReferralClient {
|
||||
auth_manager: Arc<OnceLock<Arc<AuthManager>>>,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
impl ReferralClient {
|
||||
pub fn new(auth_manager: Arc<OnceLock<Arc<AuthManager>>>, base_url: String) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn load_offer(&self) -> anyhow::Result<Option<ReferralOffer>> {
|
||||
let manager = self.auth_manager()?;
|
||||
let (auth, identity) = self.load_auth(&manager).await?;
|
||||
let client = codex_login::default_client::create_client();
|
||||
let base_url = self.base_url()?;
|
||||
|
||||
let response = client
|
||||
.get(format!(
|
||||
"{base_url}/referrals/invite/eligibility?referral_key={REFERRAL_KEY}&requested_referrals=1&supports_rewardless_invites=false"
|
||||
))
|
||||
.headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers())
|
||||
.timeout(ELIGIBILITY_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("referral eligibility request failed")?;
|
||||
if response.status().as_u16() == 403 {
|
||||
return Ok(None);
|
||||
}
|
||||
let status = response.status();
|
||||
anyhow::ensure!(
|
||||
status.is_success(),
|
||||
"referral eligibility failed ({status})"
|
||||
);
|
||||
let eligibility: EligibilityResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("referral eligibility response was invalid")?;
|
||||
if !eligibility.should_show || !eligibility.has_rewards {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response = client
|
||||
.get(format!(
|
||||
"{base_url}/wham/referrals/eligibility_rules?referral_key={REFERRAL_KEY}"
|
||||
))
|
||||
.headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers())
|
||||
.timeout(ELIGIBILITY_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("referral rules request failed")?;
|
||||
if response.status().as_u16() == 403 {
|
||||
return Ok(None);
|
||||
}
|
||||
let status = response.status();
|
||||
anyhow::ensure!(status.is_success(), "referral rules failed ({status})");
|
||||
let rules: RulesResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("referral rules response was invalid")?;
|
||||
|
||||
let description = eligibility
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_description(&eligibility));
|
||||
|
||||
Ok(Some(ReferralOffer {
|
||||
description,
|
||||
rules: rules.rules,
|
||||
grant_action: eligibility.grant_action,
|
||||
grant_amount: eligibility.grant_amount,
|
||||
requires_explicit_confirmation: rules.requires_explicit_confirmation,
|
||||
identity,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn send_invite(
|
||||
&self,
|
||||
offer: &ReferralOffer,
|
||||
email: &str,
|
||||
) -> anyhow::Result<ReferralRewardStatus> {
|
||||
let current_offer = match self.load_offer().await {
|
||||
Ok(Some(offer)) => offer,
|
||||
Ok(None) => {
|
||||
return Err(preflight_failure("referral offer is no longer available"));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(preflight_failure(format!(
|
||||
"referral offer could not be revalidated: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if ¤t_offer != offer {
|
||||
return Err(preflight_failure(
|
||||
"referral offer changed before invite send",
|
||||
));
|
||||
}
|
||||
|
||||
let manager = self.auth_manager()?;
|
||||
let (auth, identity) = self
|
||||
.load_auth(&manager)
|
||||
.await
|
||||
.map_err(|err| preflight_failure(format!("ChatGPT authentication changed: {err}")))?;
|
||||
if identity != offer.identity {
|
||||
return Err(preflight_failure("referral account changed"));
|
||||
}
|
||||
|
||||
let client = codex_login::default_client::create_single_attempt_client();
|
||||
let base_url = self.base_url()?;
|
||||
let response = client
|
||||
.post(format!("{base_url}/wham/referrals/invite"))
|
||||
.headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers())
|
||||
.json(&InviteRequest {
|
||||
referral_key: REFERRAL_KEY,
|
||||
emails: [email],
|
||||
})
|
||||
.timeout(INVITE_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("referral invite request failed")?;
|
||||
let status = response.status();
|
||||
if matches!(status.as_u16(), 400 | 403 | 409 | 422) {
|
||||
return Err(DefiniteReferralInviteRejection {
|
||||
status: status.as_u16(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
anyhow::ensure!(status.is_success(), "referral invite failed ({status})");
|
||||
let response: InviteResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("referral invite response was invalid")?;
|
||||
Ok(match response.has_rewards {
|
||||
Some(true) => ReferralRewardStatus::Included,
|
||||
Some(false) => ReferralRewardStatus::NotIncluded,
|
||||
None => ReferralRewardStatus::Unknown,
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_manager(&self) -> anyhow::Result<Arc<AuthManager>> {
|
||||
self.auth_manager
|
||||
.get()
|
||||
.cloned()
|
||||
.context("ChatGPT authentication is not ready")
|
||||
}
|
||||
|
||||
async fn load_auth(
|
||||
&self,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
) -> anyhow::Result<(CodexAuth, ReferralIdentity)> {
|
||||
let auth = self
|
||||
.auth_from_manager(auth_manager)
|
||||
.await
|
||||
.context("ChatGPT authentication is unavailable")?;
|
||||
let identity = Self::identity_from_auth(&auth)?;
|
||||
Ok((auth, identity))
|
||||
}
|
||||
|
||||
async fn auth_from_manager(
|
||||
&self,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
) -> anyhow::Result<CodexAuth> {
|
||||
let auth = auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.context("ChatGPT authentication is unavailable")?;
|
||||
anyhow::ensure!(
|
||||
auth.uses_codex_backend(),
|
||||
"referrals require ChatGPT authentication"
|
||||
);
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
fn identity_from_auth(auth: &CodexAuth) -> anyhow::Result<ReferralIdentity> {
|
||||
let user_id = auth
|
||||
.get_chatgpt_user_id()
|
||||
.context("ChatGPT user ID is unavailable")?;
|
||||
let account_id = auth
|
||||
.get_account_id()
|
||||
.context("ChatGPT account ID is unavailable")?;
|
||||
Ok(ReferralIdentity {
|
||||
user_id,
|
||||
account_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn base_url(&self) -> anyhow::Result<&str> {
|
||||
let base_url = self.base_url.trim_end_matches('/');
|
||||
anyhow::ensure!(
|
||||
base_url.contains("/backend-api"),
|
||||
"referrals require a ChatGPT backend URL"
|
||||
);
|
||||
Ok(base_url)
|
||||
}
|
||||
}
|
||||
|
||||
fn preflight_failure(message: impl Into<String>) -> anyhow::Error {
|
||||
ReferralInvitePreflightFailure {
|
||||
message: message.into(),
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
fn fallback_description(eligibility: &EligibilityResponse) -> String {
|
||||
match (&eligibility.grant_amount, &eligibility.grant_action) {
|
||||
(Some(amount), Some(action)) => format!(
|
||||
"Invite someone and earn {} {}.",
|
||||
reward_value_to_string(amount),
|
||||
reward_value_to_string(action)
|
||||
),
|
||||
(Some(amount), None) => format!(
|
||||
"Invite someone and earn a Codex reward worth {}.",
|
||||
reward_value_to_string(amount)
|
||||
),
|
||||
(None, Some(action)) => format!(
|
||||
"Invite someone and earn a Codex {} reward.",
|
||||
reward_value_to_string(action)
|
||||
),
|
||||
(None, None) => "Invite someone and earn a Codex reward.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reward_value_to_string(value: &Value) -> String {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
}
|
||||
@@ -202,6 +202,37 @@ pub fn create_client() -> CodexHttpClient {
|
||||
CodexHttpClient::new(inner)
|
||||
}
|
||||
|
||||
/// Create an HTTP client for non-idempotent requests that must not be retried or redirected.
|
||||
pub fn create_single_attempt_client() -> CodexHttpClient {
|
||||
let inner = try_build_single_attempt_reqwest_client().unwrap_or_else(|error| {
|
||||
tracing::warn!(error = %error, "failed to build single-attempt reqwest client");
|
||||
with_chatgpt_cloudflare_cookie_store(
|
||||
reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.retry(reqwest::retry::never()),
|
||||
)
|
||||
.build()
|
||||
.unwrap_or_else(|fallback_error| {
|
||||
tracing::warn!(
|
||||
error = %fallback_error,
|
||||
"failed to build fallback single-attempt reqwest client with ChatGPT Cloudflare cookie store"
|
||||
);
|
||||
reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.retry(reqwest::retry::never())
|
||||
.build()
|
||||
.unwrap_or_else(|final_error| {
|
||||
tracing::error!(
|
||||
error = %final_error,
|
||||
"failed to build fallback single-attempt reqwest client"
|
||||
);
|
||||
std::process::abort()
|
||||
})
|
||||
})
|
||||
});
|
||||
CodexHttpClient::new(inner)
|
||||
}
|
||||
|
||||
/// Builds the default reqwest client used for ordinary Codex HTTP traffic.
|
||||
///
|
||||
/// This starts from the standard Codex user agent, default headers, and sandbox-specific proxy
|
||||
@@ -235,6 +266,16 @@ pub fn try_build_reqwest_client() -> Result<reqwest::Client, BuildCustomCaTransp
|
||||
build_reqwest_client_with_custom_ca(default_reqwest_client_builder())
|
||||
}
|
||||
|
||||
/// Tries to build the default reqwest client with retries and redirects disabled.
|
||||
pub fn try_build_single_attempt_reqwest_client()
|
||||
-> Result<reqwest::Client, BuildCustomCaTransportError> {
|
||||
build_reqwest_client_with_custom_ca(
|
||||
default_reqwest_client_builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.retry(reqwest::retry::never()),
|
||||
)
|
||||
}
|
||||
|
||||
fn default_reqwest_client_builder() -> reqwest::ClientBuilder {
|
||||
let mut builder = reqwest::Client::builder().default_headers(default_headers());
|
||||
if is_sandboxed() {
|
||||
|
||||
@@ -30,6 +30,7 @@ codex-ansi-escape = { workspace = true }
|
||||
codex-app-server-client = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-chatgpt = { workspace = true }
|
||||
codex-install-context = { workspace = true }
|
||||
codex-cloud-config = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
use super::plugin_mentions::fetch_plugin_mentions;
|
||||
use super::*;
|
||||
use crate::app_event::ConnectorsSnapshot;
|
||||
use crate::app_event::ReferralInviteResult;
|
||||
use crate::app_info::app_info_from_api;
|
||||
use crate::config_update::format_config_error;
|
||||
use codex_app_server_protocol::AppsListParams;
|
||||
@@ -118,6 +119,58 @@ impl App {
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn refresh_referral_offer(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
request_id: uuid::Uuid,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let offer = match request_handle.referral_client().await.ok().flatten() {
|
||||
Some(client) => client.load_offer().await.ok().flatten(),
|
||||
None => None,
|
||||
};
|
||||
app_event_tx.send(AppEvent::ReferralOfferLoaded(
|
||||
request_id,
|
||||
offer.map(Box::new),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn send_referral_invite(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
request_id: uuid::Uuid,
|
||||
offer: codex_chatgpt::referrals::ReferralOffer,
|
||||
email: String,
|
||||
) {
|
||||
let request_handle = app_server.request_handle();
|
||||
let app_event_tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = match request_handle.referral_client().await.ok().flatten() {
|
||||
Some(client) => match client.send_invite(&offer, &email).await {
|
||||
Ok(status) => ReferralInviteResult::Sent(status),
|
||||
Err(err)
|
||||
if codex_chatgpt::referrals::is_definite_referral_invite_rejection(
|
||||
&err,
|
||||
) =>
|
||||
{
|
||||
ReferralInviteResult::Rejected
|
||||
}
|
||||
Err(err)
|
||||
if codex_chatgpt::referrals::is_referral_invite_preflight_failure(&err) =>
|
||||
{
|
||||
ReferralInviteResult::Unavailable
|
||||
}
|
||||
Err(_) => ReferralInviteResult::Unknown,
|
||||
},
|
||||
None => ReferralInviteResult::Unavailable,
|
||||
};
|
||||
app_event_tx.send(AppEvent::ReferralInviteSent(request_id, result));
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn refresh_rate_limit_reset_credits(
|
||||
&mut self,
|
||||
app_server: &AppServerSession,
|
||||
|
||||
@@ -912,6 +912,30 @@ impl App {
|
||||
self.chat_widget
|
||||
.add_token_activity_output(crate::chatwidget::TokenActivityView::Daily);
|
||||
}
|
||||
AppEvent::RefreshReferralOffer(request_id) => {
|
||||
self.refresh_referral_offer(app_server, request_id);
|
||||
}
|
||||
AppEvent::ReferralOfferLoaded(request_id, offer) => {
|
||||
self.chat_widget
|
||||
.finish_referral_offer_refresh(request_id, offer.map(|offer| *offer));
|
||||
}
|
||||
AppEvent::OpenReferralEmailPrompt => {
|
||||
self.chat_widget.show_referral_email_prompt();
|
||||
}
|
||||
AppEvent::OpenReferralConfirmation(email) => {
|
||||
self.chat_widget.show_referral_confirmation(email);
|
||||
}
|
||||
AppEvent::SendReferralInvite { email, offer } => {
|
||||
if let Some((request_id, offer)) =
|
||||
self.chat_widget.start_referral_send(&email, *offer)
|
||||
{
|
||||
self.send_referral_invite(app_server, request_id, offer, email);
|
||||
}
|
||||
}
|
||||
AppEvent::ReferralInviteSent(request_id, reward_status) => {
|
||||
self.chat_widget
|
||||
.finish_referral_send(request_id, reward_status);
|
||||
}
|
||||
AppEvent::OpenRateLimitResetCredits => {
|
||||
let request_id = self.chat_widget.show_rate_limit_reset_loading_popup();
|
||||
self.refresh_rate_limit_reset_credits(app_server, request_id);
|
||||
|
||||
@@ -28,12 +28,15 @@ use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::PluginUninstallResponse;
|
||||
use codex_app_server_protocol::SkillsListResponse;
|
||||
use codex_app_server_protocol::ThreadGoalStatus;
|
||||
use codex_chatgpt::referrals::ReferralOffer;
|
||||
use codex_chatgpt::referrals::ReferralRewardStatus;
|
||||
use codex_connectors::AppInfo;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_approval_presets::ApprovalPreset;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_server_session::AppServerStartedThread;
|
||||
@@ -320,6 +323,27 @@ pub(crate) enum AppEvent {
|
||||
/// Open the default token-activity view selected from the `/usage` menu.
|
||||
OpenTokenActivity,
|
||||
|
||||
/// Check whether the signed-in account has a rewarded referral offer.
|
||||
RefreshReferralOffer(Uuid),
|
||||
|
||||
/// Result of the client-side referral eligibility check.
|
||||
ReferralOfferLoaded(Uuid, Option<Box<ReferralOffer>>),
|
||||
|
||||
/// Open the referral email input.
|
||||
OpenReferralEmailPrompt,
|
||||
|
||||
/// Show the final referral confirmation for one email address.
|
||||
OpenReferralConfirmation(String),
|
||||
|
||||
/// Send one referral invite using the offer that was shown to the user.
|
||||
SendReferralInvite {
|
||||
email: String,
|
||||
offer: Box<ReferralOffer>,
|
||||
},
|
||||
|
||||
/// Result of sending one referral invite.
|
||||
ReferralInviteSent(Uuid, ReferralInviteResult),
|
||||
|
||||
/// Open the reset-credit flow selected from the `/usage` menu.
|
||||
OpenRateLimitResetCredits,
|
||||
|
||||
@@ -1073,6 +1097,14 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum ReferralInviteResult {
|
||||
Sent(ReferralRewardStatus),
|
||||
Rejected,
|
||||
Unavailable,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Named profile selection to apply after any required UI guardrails complete.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PermissionProfileSelection {
|
||||
|
||||
@@ -33,6 +33,7 @@ pub(crate) struct CustomPromptView {
|
||||
title: String,
|
||||
placeholder: String,
|
||||
context_label: Option<String>,
|
||||
view_id: Option<&'static str>,
|
||||
on_submit: PromptSubmitted,
|
||||
|
||||
// UI state
|
||||
@@ -60,6 +61,7 @@ impl CustomPromptView {
|
||||
title,
|
||||
placeholder,
|
||||
context_label,
|
||||
view_id: None,
|
||||
on_submit,
|
||||
textarea,
|
||||
textarea_state: RefCell::new(TextAreaState::default()),
|
||||
@@ -68,6 +70,11 @@ impl CustomPromptView {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_view_id(mut self, view_id: &'static str) -> Self {
|
||||
self.view_id = Some(view_id);
|
||||
self
|
||||
}
|
||||
|
||||
fn handle_key_event_at(&mut self, key_event: KeyEvent, now: Instant) {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
@@ -143,6 +150,10 @@ impl BottomPaneView for CustomPromptView {
|
||||
self.completion
|
||||
}
|
||||
|
||||
fn view_id(&self) -> Option<&'static str> {
|
||||
self.view_id
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
if pasted.is_empty() {
|
||||
return false;
|
||||
|
||||
@@ -388,6 +388,7 @@ pub(crate) use self::rate_limits::fallback_limit_label;
|
||||
use self::rate_limits::is_app_server_cyber_policy_error;
|
||||
pub(crate) use self::rate_limits::limit_label_for_window;
|
||||
mod reasoning_shortcuts;
|
||||
mod referrals;
|
||||
mod rendering;
|
||||
mod replay;
|
||||
mod review;
|
||||
@@ -562,6 +563,7 @@ pub(crate) struct ChatWidget {
|
||||
pending_rate_limit_reset_hint: Option<PlainHistoryCell>,
|
||||
available_rate_limit_reset_credits: Option<i64>,
|
||||
next_rate_limit_reset_request_id: u64,
|
||||
referral_state: referrals::ReferralState,
|
||||
plan_type: Option<PlanType>,
|
||||
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
|
||||
@@ -135,6 +135,7 @@ impl ChatWidget {
|
||||
pending_rate_limit_reset_hint: None,
|
||||
available_rate_limit_reset_credits: None,
|
||||
next_rate_limit_reset_request_id: 0,
|
||||
referral_state: referrals::ReferralState::default(),
|
||||
plan_type: initial_plan_type,
|
||||
codex_rate_limit_reached_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
use codex_chatgpt::referrals::ReferralOffer;
|
||||
use codex_chatgpt::referrals::ReferralRewardStatus;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_event::ReferralInviteResult;
|
||||
|
||||
use super::usage::USAGE_MENU_VIEW_ID;
|
||||
use super::*;
|
||||
|
||||
const REFERRAL_VIEW_ID: &str = "referral-invite";
|
||||
pub(super) const REFERRAL_OFFER_MAX_AGE: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ReferralState {
|
||||
offer: Option<ReferralOffer>,
|
||||
pub(super) offer_loaded_at: Option<Instant>,
|
||||
pending_request_id: Option<Uuid>,
|
||||
pending_email: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn start_referral_offer_refresh(&mut self) {
|
||||
if !self.has_chatgpt_account {
|
||||
return;
|
||||
}
|
||||
let request_id = Uuid::new_v4();
|
||||
self.referral_state.pending_request_id = Some(request_id);
|
||||
self.app_event_tx
|
||||
.send(AppEvent::RefreshReferralOffer(request_id));
|
||||
}
|
||||
|
||||
pub(crate) fn finish_referral_offer_refresh(
|
||||
&mut self,
|
||||
request_id: Uuid,
|
||||
offer: Option<ReferralOffer>,
|
||||
) {
|
||||
if self.referral_state.pending_request_id != Some(request_id) {
|
||||
return;
|
||||
}
|
||||
self.referral_state.pending_request_id = None;
|
||||
self.referral_state.offer_loaded_at = offer.as_ref().map(|_| Instant::now());
|
||||
self.referral_state.offer = offer;
|
||||
|
||||
let selected = self
|
||||
.bottom_pane
|
||||
.selected_index_for_active_view(USAGE_MENU_VIEW_ID);
|
||||
let mut params = self.usage_menu_params();
|
||||
params.initial_selected_idx = selected;
|
||||
if self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params)
|
||||
{
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn referral_menu_item(&self) -> Option<SelectionItem> {
|
||||
let offer = self.current_referral_offer()?;
|
||||
Some(SelectionItem {
|
||||
name: "Invite someone to Codex".to_string(),
|
||||
description: Some(offer.description.clone()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenReferralEmailPrompt);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn show_referral_email_prompt(&mut self) {
|
||||
let tx = self.app_event_tx.clone();
|
||||
let view = CustomPromptView::new(
|
||||
"Invite someone to Codex".to_string(),
|
||||
"name@example.com".to_string(),
|
||||
String::new(),
|
||||
Some("Enter one email address, then press Enter.".to_string()),
|
||||
Box::new(move |email: String| {
|
||||
let email = email.trim().to_string();
|
||||
if !email.is_empty() {
|
||||
tx.send(AppEvent::OpenReferralConfirmation(email));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.with_view_id(REFERRAL_VIEW_ID);
|
||||
self.bottom_pane.show_view(Box::new(view));
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn show_referral_confirmation(&mut self, email: String) {
|
||||
let Some(offer) = self.current_referral_offer().cloned() else {
|
||||
self.show_referral_message(
|
||||
"This referral offer is no longer available. Reopen /usage to check again.",
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let build_params = || {
|
||||
let mut header = vec![
|
||||
Line::from("Send referral invite?").bold(),
|
||||
Line::from(offer.description.clone()).dim(),
|
||||
Line::from(format!("Recipient: {email}")).dim(),
|
||||
];
|
||||
header.extend(
|
||||
offer
|
||||
.rules
|
||||
.iter()
|
||||
.map(|rule| Line::from(format!("• {rule}")).dim()),
|
||||
);
|
||||
if offer.requires_explicit_confirmation {
|
||||
header.push(
|
||||
Line::from("By sending, you confirm that you have this person's consent.")
|
||||
.dim(),
|
||||
);
|
||||
}
|
||||
let email_for_action = email.clone();
|
||||
let offer_for_action = offer.clone();
|
||||
SelectionViewParams {
|
||||
view_id: Some(REFERRAL_VIEW_ID),
|
||||
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Send invite".to_string(),
|
||||
actions: vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::SendReferralInvite {
|
||||
email: email_for_action.clone(),
|
||||
offer: Box::new(offer_for_action.clone()),
|
||||
});
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Cancel".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
initial_selected_idx: Some(1),
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
if !self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_present(REFERRAL_VIEW_ID, build_params())
|
||||
&& !self
|
||||
.bottom_pane
|
||||
.replace_active_views_with_selection_view(&[REFERRAL_VIEW_ID], build_params())
|
||||
{
|
||||
self.bottom_pane.show_selection_view(build_params());
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn start_referral_send(
|
||||
&mut self,
|
||||
email: &str,
|
||||
confirmed_offer: ReferralOffer,
|
||||
) -> Option<(Uuid, ReferralOffer)> {
|
||||
let Some(current_offer) = self.current_referral_offer() else {
|
||||
self.show_referral_message(
|
||||
"This referral offer is no longer available. Reopen /usage to check again.",
|
||||
);
|
||||
return None;
|
||||
};
|
||||
if current_offer != &confirmed_offer {
|
||||
self.show_referral_message(
|
||||
"This referral offer changed. Reopen /usage and confirm the current offer.",
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let request_id = Uuid::new_v4();
|
||||
self.referral_state.pending_request_id = Some(request_id);
|
||||
self.referral_state.pending_email = Some(email.to_string());
|
||||
let params = SelectionViewParams {
|
||||
view_id: Some(REFERRAL_VIEW_ID),
|
||||
title: Some("Invite someone to Codex".to_string()),
|
||||
subtitle: Some(format!("Sending an invite to {email}...")),
|
||||
items: vec![SelectionItem {
|
||||
name: "Sending invite...".to_string(),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
allow_cancel: false,
|
||||
..Default::default()
|
||||
};
|
||||
if !self
|
||||
.bottom_pane
|
||||
.replace_active_views_with_selection_view(&[REFERRAL_VIEW_ID], params)
|
||||
{
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
view_id: Some(REFERRAL_VIEW_ID),
|
||||
title: Some("Invite someone to Codex".to_string()),
|
||||
subtitle: Some(format!("Sending an invite to {email}...")),
|
||||
items: vec![SelectionItem {
|
||||
name: "Sending invite...".to_string(),
|
||||
is_disabled: true,
|
||||
..Default::default()
|
||||
}],
|
||||
allow_cancel: false,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
self.request_redraw();
|
||||
Some((request_id, confirmed_offer))
|
||||
}
|
||||
|
||||
pub(crate) fn finish_referral_send(&mut self, request_id: Uuid, result: ReferralInviteResult) {
|
||||
if self.referral_state.pending_request_id != Some(request_id) {
|
||||
return;
|
||||
}
|
||||
self.referral_state.pending_request_id = None;
|
||||
let email = self.referral_state.pending_email.take().unwrap_or_default();
|
||||
let message = match result {
|
||||
ReferralInviteResult::Sent(ReferralRewardStatus::Included) => {
|
||||
format!("Invite sent to {email}.")
|
||||
}
|
||||
ReferralInviteResult::Sent(ReferralRewardStatus::NotIncluded) => {
|
||||
format!("Invite sent to {email}, but it did not include a reward.")
|
||||
}
|
||||
ReferralInviteResult::Sent(ReferralRewardStatus::Unknown) => {
|
||||
format!("Invite sent to {email}; reward status wasn't confirmed.")
|
||||
}
|
||||
ReferralInviteResult::Rejected => {
|
||||
"We couldn't send that invite. Check the email address and try again.".to_string()
|
||||
}
|
||||
ReferralInviteResult::Unavailable => {
|
||||
"This referral offer changed. Reopen /usage and confirm the current offer."
|
||||
.to_string()
|
||||
}
|
||||
ReferralInviteResult::Unknown => {
|
||||
"We couldn't confirm the invite. Check with the recipient before trying again."
|
||||
.to_string()
|
||||
}
|
||||
};
|
||||
if matches!(
|
||||
result,
|
||||
ReferralInviteResult::Sent(_) | ReferralInviteResult::Unavailable
|
||||
) {
|
||||
self.referral_state.offer = None;
|
||||
self.referral_state.offer_loaded_at = None;
|
||||
}
|
||||
self.show_referral_message(&message);
|
||||
}
|
||||
|
||||
pub(crate) fn clear_referral_state(&mut self) {
|
||||
self.referral_state = ReferralState::default();
|
||||
self.bottom_pane.dismiss_view_by_id(USAGE_MENU_VIEW_ID);
|
||||
self.bottom_pane.dismiss_view_by_id(REFERRAL_VIEW_ID);
|
||||
}
|
||||
|
||||
fn show_referral_message(&mut self, message: &str) {
|
||||
let build_params = || SelectionViewParams {
|
||||
view_id: Some(REFERRAL_VIEW_ID),
|
||||
title: Some("Invite someone to Codex".to_string()),
|
||||
subtitle: Some(message.to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![SelectionItem {
|
||||
name: "Close".to_string(),
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
if !self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_present(REFERRAL_VIEW_ID, build_params())
|
||||
&& !self
|
||||
.bottom_pane
|
||||
.replace_active_views_with_selection_view(&[REFERRAL_VIEW_ID], build_params())
|
||||
{
|
||||
self.bottom_pane.show_selection_view(build_params());
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn current_referral_offer(&self) -> Option<&ReferralOffer> {
|
||||
let loaded_at = self.referral_state.offer_loaded_at?;
|
||||
if loaded_at.elapsed() > REFERRAL_OFFER_MAX_AGE {
|
||||
return None;
|
||||
}
|
||||
self.referral_state.offer.as_ref()
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,7 @@ impl ChatWidget {
|
||||
// be identical across two accounts, so always invalidate account-scoped requests and data.
|
||||
self.clear_pending_token_activity_refreshes();
|
||||
self.clear_pending_rate_limit_reset_requests();
|
||||
self.clear_referral_state();
|
||||
self.codex_rate_limit_reached_type = None;
|
||||
self.rate_limit_warnings = RateLimitWarningState::default();
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/referrals.rs
|
||||
expression: "render_bottom_popup(&chat, 80)"
|
||||
---
|
||||
Usage
|
||||
View account usage or redeem an earned reset.
|
||||
|
||||
› 1. Show usage View recent account token usage.
|
||||
2. Redeem usage limit reset Check reset availability.
|
||||
3. Invite someone to Codex Invite a friend and earn a usage reset.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -248,6 +248,7 @@ mod plan_mode;
|
||||
#[path = "tests/plugin_catalog_tests.rs"]
|
||||
mod plugin_catalog;
|
||||
mod popups_and_settings;
|
||||
mod referrals;
|
||||
mod review_mode;
|
||||
mod side;
|
||||
mod slash_commands;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
use super::*;
|
||||
use codex_chatgpt::referrals::ReferralIdentity;
|
||||
use codex_chatgpt::referrals::ReferralOffer;
|
||||
use codex_chatgpt::referrals::ReferralRewardStatus;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_event::ReferralInviteResult;
|
||||
use crate::chatwidget::referrals::REFERRAL_OFFER_MAX_AGE;
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_menu_adds_eligible_referral_offer() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
let request_id = referral_refresh_request(&mut rx);
|
||||
chat.finish_referral_offer_refresh(request_id, Some(test_offer()));
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"usage_menu_with_referral_offer",
|
||||
render_bottom_popup(&chat, /*width*/ 80)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn referral_popup_flow_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let offer = test_offer();
|
||||
chat.start_referral_offer_refresh();
|
||||
let request_id = referral_refresh_request(&mut rx);
|
||||
chat.finish_referral_offer_refresh(request_id, Some(offer));
|
||||
|
||||
chat.show_referral_email_prompt();
|
||||
let email_prompt = render_bottom_popup(&chat, /*width*/ 80);
|
||||
chat.show_referral_confirmation("friend@example.com".to_string());
|
||||
let confirmation = render_bottom_popup(&chat, /*width*/ 50);
|
||||
let confirmed_offer = test_offer();
|
||||
let (send_request_id, _) = chat
|
||||
.start_referral_send("friend@example.com", confirmed_offer)
|
||||
.expect("eligible offer starts send");
|
||||
let sending = render_bottom_popup(&chat, /*width*/ 80);
|
||||
chat.finish_referral_send(
|
||||
send_request_id,
|
||||
ReferralInviteResult::Sent(ReferralRewardStatus::Included),
|
||||
);
|
||||
let success = render_bottom_popup(&chat, /*width*/ 80);
|
||||
assert!(
|
||||
chat.bottom_pane
|
||||
.dismiss_active_view_if_id("referral-invite"),
|
||||
"success popup should be dismissible"
|
||||
);
|
||||
assert_ne!(
|
||||
chat.bottom_pane.active_view_id(),
|
||||
Some("referral-invite"),
|
||||
"closing success should not reveal stale referral UI"
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
"referral_popup_flow",
|
||||
format!(
|
||||
"EMAIL\n{email_prompt}\nCONFIRMATION\n{confirmation}\nSENDING\n{sending}\nSUCCESS\n{success}"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_change_invalidates_referral_offer() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
let offer = test_offer();
|
||||
chat.start_referral_offer_refresh();
|
||||
let stale_request_id = referral_refresh_request(&mut rx);
|
||||
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true,
|
||||
);
|
||||
chat.start_referral_offer_refresh();
|
||||
let current_request_id = referral_refresh_request(&mut rx);
|
||||
assert_ne!(stale_request_id, current_request_id);
|
||||
chat.finish_referral_offer_refresh(stale_request_id, Some(offer));
|
||||
|
||||
assert_eq!(
|
||||
chat.start_referral_send("friend@example.com", test_offer()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_change_dismisses_open_referral_prompt() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.start_referral_offer_refresh();
|
||||
let request_id = referral_refresh_request(&mut rx);
|
||||
chat.finish_referral_offer_refresh(request_id, Some(test_offer()));
|
||||
chat.show_referral_email_prompt();
|
||||
assert_eq!(chat.bottom_pane.active_view_id(), Some("referral-invite"));
|
||||
|
||||
chat.update_account_state(
|
||||
/*status_account_display*/ None, /*plan_type*/ None,
|
||||
/*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true,
|
||||
);
|
||||
|
||||
assert_ne!(
|
||||
chat.bottom_pane.active_view_id(),
|
||||
Some("referral-invite"),
|
||||
"account changes should dismiss open referral prompts"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn referral_refresh_preserves_last_loaded_offer() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.start_referral_offer_refresh();
|
||||
let request_id = referral_refresh_request(&mut rx);
|
||||
chat.finish_referral_offer_refresh(request_id, Some(test_offer()));
|
||||
|
||||
chat.start_referral_offer_refresh();
|
||||
let _pending_request_id = referral_refresh_request(&mut rx);
|
||||
|
||||
assert!(
|
||||
chat.referral_menu_item().is_some(),
|
||||
"refreshing should not discard the last loaded offer"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn expired_referral_offer_is_not_selectable() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.start_referral_offer_refresh();
|
||||
let request_id = referral_refresh_request(&mut rx);
|
||||
chat.finish_referral_offer_refresh(request_id, Some(test_offer()));
|
||||
|
||||
chat.referral_state.offer_loaded_at =
|
||||
Some(Instant::now() - REFERRAL_OFFER_MAX_AGE - Duration::from_secs(1));
|
||||
|
||||
assert!(
|
||||
chat.referral_menu_item().is_none(),
|
||||
"expired referral offers should be hidden"
|
||||
);
|
||||
assert_eq!(
|
||||
chat.start_referral_send("friend@example.com", test_offer()),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changed_referral_offer_requires_reconfirmation_before_send() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
set_chatgpt_auth(&mut chat);
|
||||
chat.start_referral_offer_refresh();
|
||||
let request_id = referral_refresh_request(&mut rx);
|
||||
let confirmed_offer = test_offer();
|
||||
chat.finish_referral_offer_refresh(request_id, Some(confirmed_offer.clone()));
|
||||
|
||||
let mut replacement_offer = test_offer();
|
||||
replacement_offer.rules = vec!["Different terms apply.".to_string()];
|
||||
chat.start_referral_offer_refresh();
|
||||
let request_id = referral_refresh_request(&mut rx);
|
||||
chat.finish_referral_offer_refresh(request_id, Some(replacement_offer));
|
||||
|
||||
assert_eq!(
|
||||
chat.start_referral_send("friend@example.com", confirmed_offer),
|
||||
None,
|
||||
"changed referral offers should require a new confirmation"
|
||||
);
|
||||
}
|
||||
|
||||
fn test_offer() -> ReferralOffer {
|
||||
ReferralOffer {
|
||||
description: "Invite a friend and earn a usage reset.".to_string(),
|
||||
rules: vec!["Your friend must be new to Codex.".to_string()],
|
||||
grant_action: Some(serde_json::json!("usage_reset")),
|
||||
grant_amount: Some(serde_json::json!(1)),
|
||||
requires_explicit_confirmation: true,
|
||||
identity: ReferralIdentity {
|
||||
user_id: "user-1".to_string(),
|
||||
account_id: "account-1".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn referral_refresh_request(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> Uuid {
|
||||
let event = rx.try_recv().expect("referral refresh event");
|
||||
let AppEvent::RefreshReferralOffer(request_id) = event else {
|
||||
panic!("expected referral refresh, got {event:?}");
|
||||
};
|
||||
request_id
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/referrals.rs
|
||||
expression: "format!(\"EMAIL\\n{email_prompt}\\nCONFIRMATION\\n{confirmation}\\nSENDING\\n{sending}\\nSUCCESS\\n{success}\")"
|
||||
---
|
||||
EMAIL
|
||||
▌ Invite someone to Codex
|
||||
▌ Enter one email address, then press Enter.
|
||||
▌
|
||||
▌ name@example.com
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
CONFIRMATION
|
||||
Send referral invite?
|
||||
Invite a friend and earn a usage reset.
|
||||
Recipient: friend@example.com
|
||||
• Your friend must be new to Codex.
|
||||
By sending, you confirm that you have this
|
||||
person's consent.
|
||||
|
||||
1. Send invite
|
||||
› 2. Cancel
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
SENDING
|
||||
Invite someone to Codex
|
||||
Sending an invite to friend@example.com...
|
||||
|
||||
› Sending invite...
|
||||
SUCCESS
|
||||
Invite someone to Codex
|
||||
Invite sent to friend@example.com.
|
||||
|
||||
› 1. Close
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
@@ -18,6 +18,7 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() {
|
||||
));
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
expect_referral_refresh(&mut rx);
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"usage_command_menu",
|
||||
@@ -39,6 +40,7 @@ async fn usage_command_disables_reset_after_cached_zero_snapshot() {
|
||||
));
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
expect_referral_refresh(&mut rx);
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"usage_command_menu_without_resets",
|
||||
@@ -67,6 +69,7 @@ async fn usage_menu_refresh_enables_newly_available_reset() {
|
||||
));
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
expect_referral_refresh(&mut rx);
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
@@ -96,6 +99,7 @@ async fn usage_menu_refresh_failure_preserves_disabled_known_zero() {
|
||||
));
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
expect_referral_refresh(&mut rx);
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
@@ -125,6 +129,7 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 0 }),
|
||||
));
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
expect_referral_refresh(&mut rx);
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
@@ -154,6 +159,7 @@ async fn usage_command_can_check_reset_availability_before_startup_refresh_finis
|
||||
chat.start_rate_limit_reset_startup_check();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
expect_referral_refresh(&mut rx);
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"usage_command_menu_before_reset_refresh",
|
||||
@@ -171,6 +177,7 @@ async fn usage_command_can_check_reset_availability_for_workspace_accounts() {
|
||||
chat.plan_type = Some(PlanType::Business);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
expect_referral_refresh(&mut rx);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
@@ -188,6 +195,7 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() {
|
||||
Ok(RateLimitResetCreditsSummary { available_count: 2 }),
|
||||
));
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
expect_referral_refresh(&mut rx);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
@@ -405,6 +413,7 @@ async fn no_credit_outcome_disables_reset_entry_in_usage_menu() {
|
||||
dismiss_popup(&mut chat);
|
||||
|
||||
chat.dispatch_command(SlashCommand::Usage);
|
||||
expect_referral_refresh(&mut rx);
|
||||
assert_matches!(
|
||||
rx.try_recv(),
|
||||
Ok(AppEvent::RefreshRateLimits {
|
||||
@@ -756,6 +765,14 @@ fn finish_reset_consume_outcome(
|
||||
)
|
||||
}
|
||||
|
||||
fn expect_referral_refresh(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>) -> Uuid {
|
||||
let event = rx.try_recv().expect("referral eligibility refresh event");
|
||||
let AppEvent::RefreshReferralOffer(request_id) = event else {
|
||||
panic!("expected referral refresh, got {event:?}");
|
||||
};
|
||||
request_id
|
||||
}
|
||||
|
||||
fn record_popup(chat: &ChatWidget, states: &mut Vec<String>) {
|
||||
states.push(render_bottom_popup(chat, /*width*/ 80));
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ use uuid::Uuid;
|
||||
use super::rate_limits::get_limits_duration;
|
||||
use super::*;
|
||||
|
||||
const USAGE_MENU_VIEW_ID: &str = "usage-menu";
|
||||
pub(super) const USAGE_MENU_VIEW_ID: &str = "usage-menu";
|
||||
const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset";
|
||||
|
||||
impl ChatWidget {
|
||||
pub(super) fn open_usage_menu(&mut self) {
|
||||
self.clear_pending_rate_limit_reset_hint();
|
||||
self.start_referral_offer_refresh();
|
||||
let should_refresh_reset_availability = self.available_rate_limit_reset_credits == Some(0);
|
||||
self.bottom_pane
|
||||
.show_selection_view(self.usage_menu_params());
|
||||
@@ -25,7 +26,7 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn usage_menu_params(&self) -> SelectionViewParams {
|
||||
pub(super) fn usage_menu_params(&self) -> SelectionViewParams {
|
||||
let reset_eligible = self.has_chatgpt_account;
|
||||
let (reset_action_enabled, reset_description) =
|
||||
match (reset_eligible, self.available_rate_limit_reset_credits) {
|
||||
@@ -41,32 +42,37 @@ impl ChatWidget {
|
||||
(false, "No usage limit resets available.".to_string())
|
||||
}
|
||||
};
|
||||
let mut items = vec![
|
||||
SelectionItem {
|
||||
name: "Show usage".to_string(),
|
||||
description: Some("View recent account token usage.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenTokenActivity);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Redeem usage limit reset".to_string(),
|
||||
description: Some(reset_description),
|
||||
is_disabled: !reset_action_enabled,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenRateLimitResetCredits);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
if let Some(referral_item) = self.referral_menu_item() {
|
||||
items.push(referral_item);
|
||||
}
|
||||
|
||||
SelectionViewParams {
|
||||
view_id: Some(USAGE_MENU_VIEW_ID),
|
||||
title: Some("Usage".to_string()),
|
||||
subtitle: Some("View account usage or redeem an earned reset.".to_string()),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
SelectionItem {
|
||||
name: "Show usage".to_string(),
|
||||
description: Some("View recent account token usage.".to_string()),
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenTokenActivity);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Redeem usage limit reset".to_string(),
|
||||
description: Some(reset_description),
|
||||
is_disabled: !reset_action_enabled,
|
||||
actions: vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenRateLimitResetCredits);
|
||||
})],
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
items,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -87,7 +93,11 @@ impl ChatWidget {
|
||||
if let Ok(response) = result {
|
||||
self.available_rate_limit_reset_credits = Some(response.available_count);
|
||||
}
|
||||
let params = self.usage_menu_params();
|
||||
let selected = self
|
||||
.bottom_pane
|
||||
.selected_index_for_active_view(USAGE_MENU_VIEW_ID);
|
||||
let mut params = self.usage_menu_params();
|
||||
params.initial_selected_idx = selected;
|
||||
if self
|
||||
.bottom_pane
|
||||
.replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params)
|
||||
|
||||
@@ -197,6 +197,65 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
AppEvent::RefreshReferralOffer(request_id) => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "app_event",
|
||||
"variant": "RefreshReferralOffer",
|
||||
"request_id": request_id,
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
AppEvent::ReferralOfferLoaded(request_id, offer) => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "app_event",
|
||||
"variant": "ReferralOfferLoaded",
|
||||
"request_id": request_id,
|
||||
"has_offer": offer.is_some(),
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
AppEvent::OpenReferralEmailPrompt => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "app_event",
|
||||
"variant": "OpenReferralEmailPrompt",
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
AppEvent::OpenReferralConfirmation(_) => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "app_event",
|
||||
"variant": "OpenReferralConfirmation",
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
AppEvent::SendReferralInvite { .. } => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "app_event",
|
||||
"variant": "SendReferralInvite",
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
AppEvent::ReferralInviteSent(request_id, result) => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "app_event",
|
||||
"variant": "ReferralInviteSent",
|
||||
"request_id": request_id,
|
||||
"result": format!("{result:?}").split('(').next().unwrap_or("Unknown"),
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
// Noise or control flow – record variant only
|
||||
other => {
|
||||
let value = json!({
|
||||
|
||||
Reference in New Issue
Block a user