mirror of
https://github.com/openai/codex.git
synced 2026-04-28 02:11:08 +03:00
- rotate the paid-plan startup promo slot 50/50 between the existing Codex App promo and a new Fast mode promo - keep the Fast mode call to action platform-neutral so Windows can show the same tip - add a focused unit test to ensure the paid promo pool actually rotates --------- Co-authored-by: Codex <noreply@openai.com>
412 lines
12 KiB
Rust
412 lines
12 KiB
Rust
use codex_core::features::FEATURES;
|
||
use codex_protocol::account::PlanType;
|
||
use lazy_static::lazy_static;
|
||
use rand::Rng;
|
||
|
||
const ANNOUNCEMENT_TIP_URL: &str =
|
||
"https://raw.githubusercontent.com/openai/codex/main/announcement_tip.toml";
|
||
|
||
const IS_MACOS: bool = cfg!(target_os = "macos");
|
||
const IS_WINDOWS: bool = cfg!(target_os = "windows");
|
||
|
||
const PAID_TOOLTIP: &str = "*New* Try the **Codex App** with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
|
||
const PAID_TOOLTIP_WINDOWS: &str = "*New* Try the **Codex App**, now available on **Windows**, with 2x rate limits until *April 2nd*. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
|
||
const PAID_TOOLTIP_NON_MAC: &str = "*New* 2x rate limits until *April 2nd*.";
|
||
const FAST_TOOLTIP: &str = "*New* Use **/fast** to enable our fastest inference at 2X plan usage.";
|
||
const OTHER_TOOLTIP: &str = "*New* Build faster with the **Codex App**. Run 'codex app' or visit https://chatgpt.com/codex?app-landing-page=true";
|
||
const OTHER_TOOLTIP_NON_MAC: &str = "*New* Build faster with Codex.";
|
||
const FREE_GO_TOOLTIP: &str =
|
||
"*New* Codex is included in your plan for free through *March 2nd* – let’s build together.";
|
||
|
||
const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt");
|
||
|
||
lazy_static! {
|
||
static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS
|
||
.lines()
|
||
.map(str::trim)
|
||
.filter(|line| {
|
||
if line.is_empty() || line.starts_with('#') {
|
||
return false;
|
||
}
|
||
if !IS_MACOS && !IS_WINDOWS && line.contains("codex app") {
|
||
return false;
|
||
}
|
||
true
|
||
})
|
||
.collect();
|
||
static ref ALL_TOOLTIPS: Vec<&'static str> = {
|
||
let mut tips = Vec::new();
|
||
tips.extend(TOOLTIPS.iter().copied());
|
||
tips.extend(experimental_tooltips());
|
||
tips
|
||
};
|
||
}
|
||
|
||
fn experimental_tooltips() -> Vec<&'static str> {
|
||
FEATURES
|
||
.iter()
|
||
.filter_map(|spec| spec.stage.experimental_announcement())
|
||
.collect()
|
||
}
|
||
|
||
/// Pick a random tooltip to show to the user when starting Codex.
|
||
pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Option<String> {
|
||
let mut rng = rand::rng();
|
||
|
||
if let Some(announcement) = announcement::fetch_announcement_tip() {
|
||
return Some(announcement);
|
||
}
|
||
|
||
// Leave small chance for a random tooltip to be shown.
|
||
if rng.random_ratio(8, 10) {
|
||
match plan {
|
||
Some(PlanType::Plus)
|
||
| Some(PlanType::Business)
|
||
| Some(PlanType::Team)
|
||
| Some(PlanType::Enterprise)
|
||
| Some(PlanType::Pro) => {
|
||
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
|
||
}
|
||
Some(PlanType::Go) | Some(PlanType::Free) => {
|
||
return Some(FREE_GO_TOOLTIP.to_string());
|
||
}
|
||
_ => {
|
||
let tooltip = if IS_MACOS {
|
||
OTHER_TOOLTIP
|
||
} else {
|
||
OTHER_TOOLTIP_NON_MAC
|
||
};
|
||
return Some(tooltip.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
pick_tooltip(&mut rng).map(str::to_string)
|
||
}
|
||
|
||
fn paid_app_tooltip() -> &'static str {
|
||
if IS_MACOS {
|
||
PAID_TOOLTIP
|
||
} else if IS_WINDOWS {
|
||
PAID_TOOLTIP_WINDOWS
|
||
} else {
|
||
PAID_TOOLTIP_NON_MAC
|
||
}
|
||
}
|
||
|
||
/// Paid users spend most startup sessions in a dedicated promo slot rather than the
|
||
/// generic random tip pool. Keep this business logic explicit: we currently split
|
||
/// that slot between the app promo and Fast mode, but suppress the Fast promo once
|
||
/// the user already has Fast mode enabled.
|
||
fn pick_paid_tooltip<R: Rng + ?Sized>(rng: &mut R, fast_mode_enabled: bool) -> &'static str {
|
||
if fast_mode_enabled || rng.random_bool(0.5) {
|
||
paid_app_tooltip()
|
||
} else {
|
||
FAST_TOOLTIP
|
||
}
|
||
}
|
||
|
||
fn pick_tooltip<R: Rng + ?Sized>(rng: &mut R) -> Option<&'static str> {
|
||
if ALL_TOOLTIPS.is_empty() {
|
||
None
|
||
} else {
|
||
ALL_TOOLTIPS
|
||
.get(rng.random_range(0..ALL_TOOLTIPS.len()))
|
||
.copied()
|
||
}
|
||
}
|
||
|
||
pub(crate) mod announcement {
|
||
use crate::tooltips::ANNOUNCEMENT_TIP_URL;
|
||
use crate::version::CODEX_CLI_VERSION;
|
||
use chrono::NaiveDate;
|
||
use chrono::Utc;
|
||
use regex_lite::Regex;
|
||
use serde::Deserialize;
|
||
use std::sync::OnceLock;
|
||
use std::thread;
|
||
use std::time::Duration;
|
||
|
||
static ANNOUNCEMENT_TIP: OnceLock<Option<String>> = OnceLock::new();
|
||
|
||
/// Prewarm the cache of the announcement tip.
|
||
pub(crate) fn prewarm() {
|
||
let _ = thread::spawn(|| ANNOUNCEMENT_TIP.get_or_init(init_announcement_tip_in_thread));
|
||
}
|
||
|
||
/// Fetch the announcement tip, return None if the prewarm is not done yet.
|
||
pub(crate) fn fetch_announcement_tip() -> Option<String> {
|
||
ANNOUNCEMENT_TIP
|
||
.get()
|
||
.cloned()
|
||
.flatten()
|
||
.and_then(|raw| parse_announcement_tip_toml(&raw))
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct AnnouncementTipRaw {
|
||
content: String,
|
||
from_date: Option<String>,
|
||
to_date: Option<String>,
|
||
version_regex: Option<String>,
|
||
target_app: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
struct AnnouncementTipDocument {
|
||
announcements: Vec<AnnouncementTipRaw>,
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct AnnouncementTip {
|
||
content: String,
|
||
from_date: Option<NaiveDate>,
|
||
to_date: Option<NaiveDate>,
|
||
version_regex: Option<Regex>,
|
||
target_app: String,
|
||
}
|
||
|
||
fn init_announcement_tip_in_thread() -> Option<String> {
|
||
thread::spawn(blocking_init_announcement_tip)
|
||
.join()
|
||
.ok()
|
||
.flatten()
|
||
}
|
||
|
||
fn blocking_init_announcement_tip() -> Option<String> {
|
||
// Avoid system proxy detection to prevent macOS system-configuration panics (#8912).
|
||
let client = reqwest::blocking::Client::builder()
|
||
.no_proxy()
|
||
.build()
|
||
.ok()?;
|
||
let response = client
|
||
.get(ANNOUNCEMENT_TIP_URL)
|
||
.timeout(Duration::from_millis(2000))
|
||
.send()
|
||
.ok()?;
|
||
response.error_for_status().ok()?.text().ok()
|
||
}
|
||
|
||
pub(crate) fn parse_announcement_tip_toml(text: &str) -> Option<String> {
|
||
let announcements = toml::from_str::<AnnouncementTipDocument>(text)
|
||
.map(|doc| doc.announcements)
|
||
.or_else(|_| toml::from_str::<Vec<AnnouncementTipRaw>>(text))
|
||
.ok()?;
|
||
|
||
let mut latest_match = None;
|
||
let today = Utc::now().date_naive();
|
||
for raw in announcements {
|
||
let Some(tip) = AnnouncementTip::from_raw(raw) else {
|
||
continue;
|
||
};
|
||
if tip.version_matches(CODEX_CLI_VERSION)
|
||
&& tip.date_matches(today)
|
||
&& tip.target_app == "cli"
|
||
{
|
||
latest_match = Some(tip.content);
|
||
}
|
||
}
|
||
latest_match
|
||
}
|
||
|
||
impl AnnouncementTip {
|
||
fn from_raw(raw: AnnouncementTipRaw) -> Option<Self> {
|
||
let content = raw.content.trim();
|
||
if content.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
let from_date = match raw.from_date {
|
||
Some(date) => Some(NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()?),
|
||
None => None,
|
||
};
|
||
let to_date = match raw.to_date {
|
||
Some(date) => Some(NaiveDate::parse_from_str(&date, "%Y-%m-%d").ok()?),
|
||
None => None,
|
||
};
|
||
let version_regex = match raw.version_regex {
|
||
Some(pattern) => Some(Regex::new(&pattern).ok()?),
|
||
None => None,
|
||
};
|
||
|
||
Some(Self {
|
||
content: content.to_string(),
|
||
from_date,
|
||
to_date,
|
||
version_regex,
|
||
target_app: raw.target_app.unwrap_or("cli".to_string()).to_lowercase(),
|
||
})
|
||
}
|
||
|
||
fn version_matches(&self, version: &str) -> bool {
|
||
self.version_regex
|
||
.as_ref()
|
||
.is_none_or(|regex| regex.is_match(version))
|
||
}
|
||
|
||
fn date_matches(&self, today: NaiveDate) -> bool {
|
||
if let Some(from) = self.from_date
|
||
&& today < from
|
||
{
|
||
return false;
|
||
}
|
||
if let Some(to) = self.to_date
|
||
&& today >= to
|
||
{
|
||
return false;
|
||
}
|
||
true
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::tooltips::announcement::parse_announcement_tip_toml;
|
||
use rand::SeedableRng;
|
||
use rand::rngs::StdRng;
|
||
|
||
#[test]
|
||
fn random_tooltip_returns_some_tip_when_available() {
|
||
let mut rng = StdRng::seed_from_u64(42);
|
||
assert!(pick_tooltip(&mut rng).is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn random_tooltip_is_reproducible_with_seed() {
|
||
let expected = {
|
||
let mut rng = StdRng::seed_from_u64(7);
|
||
pick_tooltip(&mut rng)
|
||
};
|
||
|
||
let mut rng = StdRng::seed_from_u64(7);
|
||
assert_eq!(expected, pick_tooltip(&mut rng));
|
||
}
|
||
|
||
#[test]
|
||
fn paid_tooltip_pool_rotates_between_promos() {
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
for seed in 0..32 {
|
||
let mut rng = StdRng::seed_from_u64(seed);
|
||
seen.insert(pick_paid_tooltip(&mut rng, false));
|
||
}
|
||
|
||
let expected = std::collections::BTreeSet::from([paid_app_tooltip(), FAST_TOOLTIP]);
|
||
assert_eq!(seen, expected);
|
||
}
|
||
|
||
#[test]
|
||
fn paid_tooltip_pool_skips_fast_when_fast_mode_is_enabled() {
|
||
let mut seen = std::collections::BTreeSet::new();
|
||
for seed in 0..8 {
|
||
let mut rng = StdRng::seed_from_u64(seed);
|
||
seen.insert(pick_paid_tooltip(&mut rng, true));
|
||
}
|
||
|
||
let expected = std::collections::BTreeSet::from([paid_app_tooltip()]);
|
||
assert_eq!(seen, expected);
|
||
assert!(!seen.contains(&FAST_TOOLTIP));
|
||
}
|
||
|
||
#[test]
|
||
fn announcement_tip_toml_picks_last_matching() {
|
||
let toml = r#"
|
||
[[announcements]]
|
||
content = "first"
|
||
from_date = "2000-01-01"
|
||
|
||
[[announcements]]
|
||
content = "latest match"
|
||
version_regex = ".*"
|
||
target_app = "cli"
|
||
|
||
[[announcements]]
|
||
content = "should not match"
|
||
to_date = "2000-01-01"
|
||
"#;
|
||
|
||
assert_eq!(
|
||
Some("latest match".to_string()),
|
||
parse_announcement_tip_toml(toml)
|
||
);
|
||
|
||
let toml = r#"
|
||
[[announcements]]
|
||
content = "first"
|
||
from_date = "2000-01-01"
|
||
target_app = "cli"
|
||
|
||
[[announcements]]
|
||
content = "latest match"
|
||
version_regex = ".*"
|
||
|
||
[[announcements]]
|
||
content = "should not match"
|
||
to_date = "2000-01-01"
|
||
"#;
|
||
|
||
assert_eq!(
|
||
Some("latest match".to_string()),
|
||
parse_announcement_tip_toml(toml)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn announcement_tip_toml_picks_no_match() {
|
||
let toml = r#"
|
||
[[announcements]]
|
||
content = "first"
|
||
from_date = "2000-01-01"
|
||
to_date = "2000-01-05"
|
||
|
||
[[announcements]]
|
||
content = "latest match"
|
||
version_regex = "invalid_version_name"
|
||
|
||
[[announcements]]
|
||
content = "should not match either "
|
||
target_app = "vsce"
|
||
"#;
|
||
|
||
assert_eq!(None, parse_announcement_tip_toml(toml));
|
||
}
|
||
|
||
#[test]
|
||
fn announcement_tip_toml_bad_deserialization() {
|
||
let toml = r#"
|
||
[[announcements]]
|
||
content = 123
|
||
from_date = "2000-01-01"
|
||
"#;
|
||
|
||
assert_eq!(None, parse_announcement_tip_toml(toml));
|
||
}
|
||
|
||
#[test]
|
||
fn announcement_tip_toml_parse_comments() {
|
||
let toml = r#"
|
||
# Example announcement tips for Codex TUI.
|
||
# Each [[announcements]] entry is evaluated in order; the last matching one is shown.
|
||
# Dates are UTC, formatted as YYYY-MM-DD. The from_date is inclusive and the to_date is exclusive.
|
||
# version_regex matches against the CLI version (env!("CARGO_PKG_VERSION")); omit to apply to all versions.
|
||
# target_app specify which app should display the announcement (cli, vsce, ...).
|
||
|
||
[[announcements]]
|
||
content = "Welcome to Codex! Check out the new onboarding flow."
|
||
from_date = "2024-10-01"
|
||
to_date = "2024-10-15"
|
||
target_app = "cli"
|
||
version_regex = "^0\\.0\\.0$"
|
||
|
||
[[announcements]]
|
||
content = "This is a test announcement"
|
||
"#;
|
||
|
||
assert_eq!(
|
||
Some("This is a test announcement".to_string()),
|
||
parse_announcement_tip_toml(toml)
|
||
);
|
||
}
|
||
}
|