mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add usage-based business plan types (#15934)
## Summary - add `self_serve_business_usage_based` and `enterprise_cbp_usage_based` to the public/internal plan enums and regenerate the app-server + Python SDK artifacts - map both plans through JWT login and backend rate-limit payloads, then bucket them with the existing Team/Business entitlement behavior in cloud requirements, usage-limit copy, tooltips, and status display - keep the earlier display-label remap commit on this branch so the new Team-like and Business-like plans render consistently in the UI ## Testing - `just write-app-server-schema` - `uv run --project sdk/python python sdk/python/scripts/update_sdk_artifacts.py generate-types` - `just fix -p codex-protocol -p codex-login -p codex-core -p codex-backend-client -p codex-cloud-requirements -p codex-tui -p codex-tui-app-server -p codex-backend-openapi-models` - `just fmt` - `just argument-comment-lint` - `cargo test -p codex-protocol usage_based_plan_types_use_expected_wire_names` - `cargo test -p codex-login usage_based` - `cargo test -p codex-backend-client usage_based` - `cargo test -p codex-cloud-requirements usage_based` - `cargo test -p codex-core usage_limit_reached_error_formats_` - `cargo test -p codex-tui plan_type_display_name_remaps_display_labels` - `cargo test -p codex-tui remapped` - `cargo test -p codex-tui-app-server plan_type_display_name_remaps_display_labels` - `cargo test -p codex-tui-app-server remapped` - `cargo test -p codex-tui-app-server preserves_usage_based_plan_type_wire_name` ## Notes - a broader multi-crate `cargo test` run still hits unrelated existing guardian-approval config failures in `codex-rs/core/src/config/config_tests.rs`
This commit is contained in:
committed by
GitHub
Unverified
parent
81abb44f68
commit
82e8031338
@@ -1777,7 +1777,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -9503,7 +9503,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -6317,7 +6317,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -52,7 +52,9 @@
|
||||
"plus",
|
||||
"pro",
|
||||
"team",
|
||||
"self_serve_business_usage_based",
|
||||
"business",
|
||||
"enterprise_cbp_usage_based",
|
||||
"enterprise",
|
||||
"edu",
|
||||
"unknown"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "business" | "enterprise" | "edu" | "unknown";
|
||||
export type PlanType = "free" | "go" | "plus" | "pro" | "team" | "self_serve_business_usage_based" | "business" | "enterprise_cbp_usage_based" | "enterprise" | "edu" | "unknown";
|
||||
|
||||
@@ -474,7 +474,13 @@ impl Client {
|
||||
crate::types::PlanType::Plus => AccountPlanType::Plus,
|
||||
crate::types::PlanType::Pro => AccountPlanType::Pro,
|
||||
crate::types::PlanType::Team => AccountPlanType::Team,
|
||||
crate::types::PlanType::SelfServeBusinessUsageBased => {
|
||||
AccountPlanType::SelfServeBusinessUsageBased
|
||||
}
|
||||
crate::types::PlanType::Business => AccountPlanType::Business,
|
||||
crate::types::PlanType::EnterpriseCbpUsageBased => {
|
||||
AccountPlanType::EnterpriseCbpUsageBased
|
||||
}
|
||||
crate::types::PlanType::Enterprise => AccountPlanType::Enterprise,
|
||||
crate::types::PlanType::Edu | crate::types::PlanType::Education => AccountPlanType::Edu,
|
||||
crate::types::PlanType::Guest
|
||||
@@ -499,6 +505,18 @@ mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn map_plan_type_supports_usage_based_business_variants() {
|
||||
assert_eq!(
|
||||
Client::map_plan_type(crate::types::PlanType::SelfServeBusinessUsageBased),
|
||||
AccountPlanType::SelfServeBusinessUsageBased
|
||||
);
|
||||
assert_eq!(
|
||||
Client::map_plan_type(crate::types::PlanType::EnterpriseCbpUsageBased),
|
||||
AccountPlanType::EnterpriseCbpUsageBased
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_payload_maps_primary_and_additional_rate_limits() {
|
||||
let payload = RateLimitStatusPayload {
|
||||
|
||||
@@ -327,11 +327,11 @@ impl CloudRequirementsService {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(plan_type) = auth.account_plan_type() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !auth.is_chatgpt_auth()
|
||||
|| !matches!(
|
||||
auth.account_plan_type(),
|
||||
Some(PlanType::Business | PlanType::Enterprise)
|
||||
)
|
||||
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -547,11 +547,11 @@ impl CloudRequirementsService {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return false;
|
||||
};
|
||||
let Some(plan_type) = auth.account_plan_type() else {
|
||||
return false;
|
||||
};
|
||||
if !auth.is_chatgpt_auth()
|
||||
|| !matches!(
|
||||
auth.account_plan_type(),
|
||||
Some(PlanType::Business | PlanType::Enterprise)
|
||||
)
|
||||
|| !(plan_type.is_business_like() || matches!(plan_type, PlanType::Enterprise))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -1125,6 +1125,20 @@ mod tests {
|
||||
assert_eq!(result, Ok(None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_skips_team_like_usage_based_plan() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager_with_plan("self_serve_business_usage_based"),
|
||||
Arc::new(StaticFetcher {
|
||||
contents: Some("allowed_approval_policies = [\"never\"]".to_string()),
|
||||
}),
|
||||
codex_home.path().to_path_buf(),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
assert_eq!(service.fetch().await, Ok(None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_allows_business_plan() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
@@ -1153,6 +1167,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_allows_business_like_usage_based_plan() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let service = CloudRequirementsService::new(
|
||||
auth_manager_with_plan("enterprise_cbp_usage_based"),
|
||||
Arc::new(StaticFetcher {
|
||||
contents: Some("allowed_approval_policies = [\"never\"]".to_string()),
|
||||
}),
|
||||
codex_home.path().to_path_buf(),
|
||||
CLOUD_REQUIREMENTS_TIMEOUT,
|
||||
);
|
||||
assert_eq!(
|
||||
service.fetch().await,
|
||||
Ok(Some(ConfigRequirementsToml {
|
||||
allowed_approval_policies: Some(vec![AskForApproval::Never]),
|
||||
allowed_sandbox_modes: None,
|
||||
allowed_web_search_modes: None,
|
||||
guardian_developer_instructions: None,
|
||||
feature_requirements: None,
|
||||
mcp_servers: None,
|
||||
apps: None,
|
||||
rules: None,
|
||||
enforce_residency: None,
|
||||
network: None,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_cloud_requirements_allows_hc_plan_as_enterprise() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
|
||||
@@ -69,8 +69,12 @@ pub enum PlanType {
|
||||
FreeWorkspace,
|
||||
#[serde(rename = "team")]
|
||||
Team,
|
||||
#[serde(rename = "self_serve_business_usage_based")]
|
||||
SelfServeBusinessUsageBased,
|
||||
#[serde(rename = "business")]
|
||||
Business,
|
||||
#[serde(rename = "enterprise_cbp_usage_based")]
|
||||
EnterpriseCbpUsageBased,
|
||||
#[serde(rename = "education")]
|
||||
Education,
|
||||
#[serde(rename = "quorum")]
|
||||
|
||||
@@ -439,7 +439,12 @@ impl std::fmt::Display for UsageLimitReachedError {
|
||||
"You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
),
|
||||
Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => {
|
||||
Some(PlanType::Known(
|
||||
KnownPlan::Team
|
||||
| KnownPlan::SelfServeBusinessUsageBased
|
||||
| KnownPlan::Business
|
||||
| KnownPlan::EnterpriseCbpUsageBased,
|
||||
)) => {
|
||||
format!(
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin{}",
|
||||
retry_suffix_after_or(self.resets_at.as_ref())
|
||||
@@ -460,21 +465,6 @@ impl std::fmt::Display for UsageLimitReachedError {
|
||||
"You've hit your usage limit.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
),
|
||||
Some(PlanType::Unknown(plan))
|
||||
if plan.eq_ignore_ascii_case("self_serve_business_usage_based") =>
|
||||
{
|
||||
match self
|
||||
.rate_limits
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.credits.as_ref())
|
||||
.map(|credits| credits.has_credits)
|
||||
{
|
||||
Some(true) => "You've hit your usage limit. Contact your admin to increase spend limits to continue."
|
||||
.to_string(),
|
||||
Some(false) | None => "You've hit your usage limit. Contact your admin to add credits to continue."
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
Some(PlanType::Unknown(_)) | None => format!(
|
||||
"You've hit your usage limit.{}",
|
||||
retry_suffix(self.resets_at.as_ref())
|
||||
|
||||
@@ -243,6 +243,34 @@ fn usage_limit_reached_error_formats_business_plan_without_reset() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_self_serve_business_usage_based_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::SelfServeBusinessUsageBased)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_enterprise_cbp_usage_based_plan() {
|
||||
let err = UsageLimitReachedError {
|
||||
plan_type: Some(PlanType::Known(KnownPlan::EnterpriseCbpUsageBased)),
|
||||
resets_at: None,
|
||||
rate_limits: Some(Box::new(rate_limit_snapshot())),
|
||||
promo_message: None,
|
||||
};
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"You've hit your usage limit. To get more access now, send a request to your admin or try again later."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_limit_reached_error_formats_default_for_other_plans() {
|
||||
let err = UsageLimitReachedError {
|
||||
|
||||
@@ -458,6 +458,52 @@ fn plan_type_maps_known_plan() {
|
||||
pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_type_maps_self_serve_business_usage_based_plan() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
let _jwt = write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: Some("self_serve_business_usage_based".to_string()),
|
||||
chatgpt_account_id: None,
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
|
||||
.expect("load auth")
|
||||
.expect("auth available");
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
auth.account_plan_type(),
|
||||
Some(AccountPlanType::SelfServeBusinessUsageBased)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_type_maps_enterprise_cbp_usage_based_plan() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
let _jwt = write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: None,
|
||||
chatgpt_plan_type: Some("enterprise_cbp_usage_based".to_string()),
|
||||
chatgpt_account_id: None,
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File)
|
||||
.expect("load auth")
|
||||
.expect("auth available");
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
auth.account_plan_type(),
|
||||
Some(AccountPlanType::EnterpriseCbpUsageBased)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_type_maps_unknown_to_unknown() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
|
||||
@@ -266,7 +266,11 @@ impl CodexAuth {
|
||||
InternalKnownPlan::Plus => AccountPlanType::Plus,
|
||||
InternalKnownPlan::Pro => AccountPlanType::Pro,
|
||||
InternalKnownPlan::Team => AccountPlanType::Team,
|
||||
InternalKnownPlan::SelfServeBusinessUsageBased => {
|
||||
AccountPlanType::SelfServeBusinessUsageBased
|
||||
}
|
||||
InternalKnownPlan::Business => AccountPlanType::Business,
|
||||
InternalKnownPlan::EnterpriseCbpUsageBased => AccountPlanType::EnterpriseCbpUsageBased,
|
||||
InternalKnownPlan::Enterprise => AccountPlanType::Enterprise,
|
||||
InternalKnownPlan::Edu => AccountPlanType::Edu,
|
||||
};
|
||||
|
||||
@@ -41,7 +41,14 @@ pub struct IdTokenInfo {
|
||||
impl IdTokenInfo {
|
||||
pub fn get_chatgpt_plan_type(&self) -> Option<String> {
|
||||
self.chatgpt_plan_type.as_ref().map(|t| match t {
|
||||
PlanType::Known(plan) => format!("{plan:?}"),
|
||||
PlanType::Known(plan) => plan.display_name().to_string(),
|
||||
PlanType::Unknown(s) => s.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_chatgpt_plan_type_raw(&self) -> Option<String> {
|
||||
self.chatgpt_plan_type.as_ref().map(|t| match t {
|
||||
PlanType::Known(plan) => plan.raw_value().to_string(),
|
||||
PlanType::Unknown(s) => s.clone(),
|
||||
})
|
||||
}
|
||||
@@ -49,9 +56,7 @@ impl IdTokenInfo {
|
||||
pub fn is_workspace_account(&self) -> bool {
|
||||
matches!(
|
||||
self.chatgpt_plan_type,
|
||||
Some(PlanType::Known(
|
||||
KnownPlan::Team | KnownPlan::Business | KnownPlan::Enterprise | KnownPlan::Edu
|
||||
))
|
||||
Some(PlanType::Known(plan)) if plan.is_workspace_account()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,7 +76,11 @@ impl PlanType {
|
||||
"plus" => Self::Known(KnownPlan::Plus),
|
||||
"pro" => Self::Known(KnownPlan::Pro),
|
||||
"team" => Self::Known(KnownPlan::Team),
|
||||
"self_serve_business_usage_based" => {
|
||||
Self::Known(KnownPlan::SelfServeBusinessUsageBased)
|
||||
}
|
||||
"business" => Self::Known(KnownPlan::Business),
|
||||
"enterprise_cbp_usage_based" => Self::Known(KnownPlan::EnterpriseCbpUsageBased),
|
||||
"enterprise" | "hc" => Self::Known(KnownPlan::Enterprise),
|
||||
"education" | "edu" => Self::Known(KnownPlan::Edu),
|
||||
_ => Self::Unknown(raw.to_string()),
|
||||
@@ -79,7 +88,7 @@ impl PlanType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum KnownPlan {
|
||||
Free,
|
||||
@@ -87,12 +96,60 @@ pub enum KnownPlan {
|
||||
Plus,
|
||||
Pro,
|
||||
Team,
|
||||
#[serde(rename = "self_serve_business_usage_based")]
|
||||
SelfServeBusinessUsageBased,
|
||||
Business,
|
||||
#[serde(rename = "enterprise_cbp_usage_based")]
|
||||
EnterpriseCbpUsageBased,
|
||||
#[serde(alias = "hc")]
|
||||
Enterprise,
|
||||
Edu,
|
||||
}
|
||||
|
||||
impl KnownPlan {
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Free => "Free",
|
||||
Self::Go => "Go",
|
||||
Self::Plus => "Plus",
|
||||
Self::Pro => "Pro",
|
||||
Self::Team => "Team",
|
||||
Self::SelfServeBusinessUsageBased => "Self Serve Business Usage Based",
|
||||
Self::Business => "Business",
|
||||
Self::EnterpriseCbpUsageBased => "Enterprise CBP Usage Based",
|
||||
Self::Enterprise => "Enterprise",
|
||||
Self::Edu => "Edu",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn raw_value(self) -> &'static str {
|
||||
match self {
|
||||
Self::Free => "free",
|
||||
Self::Go => "go",
|
||||
Self::Plus => "plus",
|
||||
Self::Pro => "pro",
|
||||
Self::Team => "team",
|
||||
Self::SelfServeBusinessUsageBased => "self_serve_business_usage_based",
|
||||
Self::Business => "business",
|
||||
Self::EnterpriseCbpUsageBased => "enterprise_cbp_usage_based",
|
||||
Self::Enterprise => "enterprise",
|
||||
Self::Edu => "edu",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_workspace_account(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Team
|
||||
| Self::SelfServeBusinessUsageBased
|
||||
| Self::Business
|
||||
| Self::EnterpriseCbpUsageBased
|
||||
| Self::Enterprise
|
||||
| Self::Edu
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct IdClaims {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -68,6 +68,44 @@ fn id_token_info_parses_hc_plan_as_enterprise() {
|
||||
assert_eq!(info.is_workspace_account(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_token_info_parses_usage_based_business_plans() {
|
||||
let self_serve_business_jwt = fake_jwt(serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "self_serve_business_usage_based"
|
||||
}
|
||||
}));
|
||||
let self_serve_business =
|
||||
parse_chatgpt_jwt_claims(&self_serve_business_jwt).expect("should parse");
|
||||
assert_eq!(
|
||||
self_serve_business.get_chatgpt_plan_type().as_deref(),
|
||||
Some("Self Serve Business Usage Based")
|
||||
);
|
||||
assert_eq!(
|
||||
self_serve_business.get_chatgpt_plan_type_raw().as_deref(),
|
||||
Some("self_serve_business_usage_based")
|
||||
);
|
||||
assert_eq!(self_serve_business.is_workspace_account(), true);
|
||||
|
||||
let enterprise_cbp_jwt = fake_jwt(serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_plan_type": "enterprise_cbp_usage_based"
|
||||
}
|
||||
}));
|
||||
let enterprise_cbp = parse_chatgpt_jwt_claims(&enterprise_cbp_jwt).expect("should parse");
|
||||
assert_eq!(
|
||||
enterprise_cbp.get_chatgpt_plan_type().as_deref(),
|
||||
Some("Enterprise CBP Usage Based")
|
||||
);
|
||||
assert_eq!(
|
||||
enterprise_cbp.get_chatgpt_plan_type_raw().as_deref(),
|
||||
Some("enterprise_cbp_usage_based")
|
||||
);
|
||||
assert_eq!(enterprise_cbp.is_workspace_account(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_token_info_handles_missing_fields() {
|
||||
let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" }));
|
||||
|
||||
@@ -13,9 +13,66 @@ pub enum PlanType {
|
||||
Plus,
|
||||
Pro,
|
||||
Team,
|
||||
#[serde(rename = "self_serve_business_usage_based")]
|
||||
#[ts(rename = "self_serve_business_usage_based")]
|
||||
SelfServeBusinessUsageBased,
|
||||
Business,
|
||||
#[serde(rename = "enterprise_cbp_usage_based")]
|
||||
#[ts(rename = "enterprise_cbp_usage_based")]
|
||||
EnterpriseCbpUsageBased,
|
||||
Enterprise,
|
||||
Edu,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl PlanType {
|
||||
pub fn is_team_like(self) -> bool {
|
||||
matches!(self, Self::Team | Self::SelfServeBusinessUsageBased)
|
||||
}
|
||||
|
||||
pub fn is_business_like(self) -> bool {
|
||||
matches!(self, Self::Business | Self::EnterpriseCbpUsageBased)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PlanType;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn usage_based_plan_types_use_expected_wire_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&PlanType::SelfServeBusinessUsageBased)
|
||||
.expect("self-serve business usage based should serialize"),
|
||||
"\"self_serve_business_usage_based\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&PlanType::EnterpriseCbpUsageBased)
|
||||
.expect("enterprise cbp usage based should serialize"),
|
||||
"\"enterprise_cbp_usage_based\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PlanType>("\"self_serve_business_usage_based\"")
|
||||
.expect("self-serve business usage based should deserialize"),
|
||||
PlanType::SelfServeBusinessUsageBased
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<PlanType>("\"enterprise_cbp_usage_based\"")
|
||||
.expect("enterprise cbp usage based should deserialize"),
|
||||
PlanType::EnterpriseCbpUsageBased
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_family_helpers_group_usage_based_variants_with_existing_plans() {
|
||||
assert_eq!(PlanType::Team.is_team_like(), true);
|
||||
assert_eq!(PlanType::SelfServeBusinessUsageBased.is_team_like(), true);
|
||||
assert_eq!(PlanType::Business.is_team_like(), false);
|
||||
|
||||
assert_eq!(PlanType::Business.is_business_like(), true);
|
||||
assert_eq!(PlanType::EnterpriseCbpUsageBased.is_business_like(), true);
|
||||
assert_eq!(PlanType::Team.is_business_like(), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,14 +94,23 @@ pub(crate) fn compose_account_display(
|
||||
CoreAuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey),
|
||||
CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => {
|
||||
let email = auth.get_account_email();
|
||||
let plan = plan
|
||||
.map(|plan_type| title_case(format!("{plan_type:?}").as_str()))
|
||||
.or_else(|| Some("Unknown".to_string()));
|
||||
let plan = plan.map(plan_type_display_name);
|
||||
let plan = plan.or_else(|| Some("Unknown".to_string()));
|
||||
Some(StatusAccountDisplay::ChatGpt { email, plan })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String {
|
||||
if plan_type.is_team_like() {
|
||||
"Business".to_string()
|
||||
} else if plan_type.is_business_like() {
|
||||
"Enterprise".to_string()
|
||||
} else {
|
||||
title_case(format!("{plan_type:?}").as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_tokens_compact(value: i64) -> String {
|
||||
let value = value.max(0);
|
||||
if value == 0 {
|
||||
@@ -187,3 +196,49 @@ pub(crate) fn title_case(s: &str) -> String {
|
||||
let rest: String = chars.as_str().to_ascii_lowercase();
|
||||
first.to_uppercase().collect::<String>() + &rest
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::auth::CodexAuth;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn plan_type_display_name_remaps_display_labels() {
|
||||
let cases = [
|
||||
(PlanType::Free, "Free"),
|
||||
(PlanType::Go, "Go"),
|
||||
(PlanType::Plus, "Plus"),
|
||||
(PlanType::Pro, "Pro"),
|
||||
(PlanType::Team, "Business"),
|
||||
(PlanType::SelfServeBusinessUsageBased, "Business"),
|
||||
(PlanType::Business, "Enterprise"),
|
||||
(PlanType::EnterpriseCbpUsageBased, "Enterprise"),
|
||||
(PlanType::Enterprise, "Enterprise"),
|
||||
(PlanType::Edu, "Edu"),
|
||||
(PlanType::Unknown, "Unknown"),
|
||||
];
|
||||
|
||||
for (plan_type, expected) in cases {
|
||||
assert_eq!(plan_type_display_name(plan_type), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_account_display_uses_remapped_plan_label() {
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
|
||||
|
||||
let display = compose_account_display(
|
||||
auth_manager.as_ref(),
|
||||
Some(PlanType::SelfServeBusinessUsageBased),
|
||||
);
|
||||
assert!(matches!(
|
||||
display,
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: Some(ref plan),
|
||||
}) if plan == "Business"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,11 +60,13 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Op
|
||||
// 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) => {
|
||||
Some(plan_type)
|
||||
if matches!(
|
||||
plan_type,
|
||||
PlanType::Plus | PlanType::Enterprise | PlanType::Pro
|
||||
) || plan_type.is_team_like()
|
||||
|| plan_type.is_business_like() =>
|
||||
{
|
||||
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
|
||||
}
|
||||
Some(PlanType::Go) | Some(PlanType::Free) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::bottom_pane::FeedbackAudience;
|
||||
use crate::status::StatusAccountDisplay;
|
||||
use crate::status::plan_type_display_name;
|
||||
use codex_app_server_client::AppServerClient;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
@@ -228,7 +229,7 @@ impl AppServerSession {
|
||||
Some(TelemetryAuthMode::Chatgpt),
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: Some(email),
|
||||
plan: Some(title_case(format!("{plan_type:?}").as_str())),
|
||||
plan: Some(plan_type_display_name(plan_type)),
|
||||
}),
|
||||
Some(plan_type),
|
||||
feedback_audience,
|
||||
@@ -733,19 +734,6 @@ impl AppServerSession {
|
||||
}
|
||||
}
|
||||
|
||||
fn title_case(s: &str) -> String {
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut chars = s.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
let rest = chars.as_str().to_ascii_lowercase();
|
||||
first.to_uppercase().collect::<String>() + &rest
|
||||
}
|
||||
|
||||
pub(crate) fn status_account_display_from_auth_mode(
|
||||
auth_mode: Option<AuthMode>,
|
||||
plan_type: Option<codex_protocol::account::PlanType>,
|
||||
@@ -755,7 +743,7 @@ pub(crate) fn status_account_display_from_auth_mode(
|
||||
Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) => {
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: plan_type.map(|plan_type| title_case(format!("{plan_type:?}").as_str())),
|
||||
plan: plan_type.map(plan_type_display_name),
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
@@ -1264,4 +1252,31 @@ mod tests {
|
||||
assert_ne!(session.history_log_id, 0);
|
||||
assert_eq!(session.history_entry_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_account_display_from_auth_mode_uses_remapped_plan_labels() {
|
||||
let business = status_account_display_from_auth_mode(
|
||||
Some(AuthMode::Chatgpt),
|
||||
Some(codex_protocol::account::PlanType::EnterpriseCbpUsageBased),
|
||||
);
|
||||
assert!(matches!(
|
||||
business,
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: Some(ref plan),
|
||||
}) if plan == "Enterprise"
|
||||
));
|
||||
|
||||
let team = status_account_display_from_auth_mode(
|
||||
Some(AuthMode::Chatgpt),
|
||||
Some(codex_protocol::account::PlanType::SelfServeBusinessUsageBased),
|
||||
);
|
||||
assert!(matches!(
|
||||
team,
|
||||
Some(StatusAccountDisplay::ChatGpt {
|
||||
email: None,
|
||||
plan: Some(ref plan),
|
||||
}) if plan == "Business"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(crate) fn load_local_chatgpt_auth(
|
||||
|
||||
let chatgpt_plan_type = tokens
|
||||
.id_token
|
||||
.get_chatgpt_plan_type()
|
||||
.get_chatgpt_plan_type_raw()
|
||||
.map(|plan_type| plan_type.to_ascii_lowercase());
|
||||
|
||||
Ok(LocalChatgptAuth {
|
||||
@@ -92,9 +92,9 @@ mod tests {
|
||||
format!("{header_b64}.{payload_b64}.{signature_b64}")
|
||||
}
|
||||
|
||||
fn write_chatgpt_auth(codex_home: &Path) {
|
||||
let id_token = fake_jwt("user@example.com", "workspace-1", "business");
|
||||
let access_token = fake_jwt("user@example.com", "workspace-1", "business");
|
||||
fn write_chatgpt_auth(codex_home: &Path, plan_type: &str) {
|
||||
let id_token = fake_jwt("user@example.com", "workspace-1", plan_type);
|
||||
let access_token = fake_jwt("user@example.com", "workspace-1", plan_type);
|
||||
let auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
@@ -114,7 +114,7 @@ mod tests {
|
||||
#[test]
|
||||
fn loads_local_chatgpt_auth_from_managed_auth() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
write_chatgpt_auth(codex_home.path());
|
||||
write_chatgpt_auth(codex_home.path(), "business");
|
||||
|
||||
let auth = load_local_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
@@ -162,7 +162,7 @@ mod tests {
|
||||
#[test]
|
||||
fn prefers_managed_auth_over_external_ephemeral_tokens() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
write_chatgpt_auth(codex_home.path());
|
||||
write_chatgpt_auth(codex_home.path(), "business");
|
||||
login_with_chatgpt_auth_tokens(
|
||||
codex_home.path(),
|
||||
&fake_jwt("user@example.com", "workspace-2", "enterprise"),
|
||||
@@ -181,4 +181,22 @@ mod tests {
|
||||
assert_eq!(auth.chatgpt_account_id, "workspace-1");
|
||||
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_usage_based_plan_type_wire_name() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
write_chatgpt_auth(codex_home.path(), "self_serve_business_usage_based");
|
||||
|
||||
let auth = load_local_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
AuthCredentialsStoreMode::File,
|
||||
Some("workspace-1"),
|
||||
)
|
||||
.expect("chatgpt auth should load");
|
||||
|
||||
assert_eq!(
|
||||
auth.chatgpt_plan_type.as_deref(),
|
||||
Some("self_serve_business_usage_based")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::project_doc::discover_project_doc_paths;
|
||||
use codex_protocol::account::PlanType;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -86,6 +87,16 @@ pub(crate) fn compose_account_display(
|
||||
account_display.cloned()
|
||||
}
|
||||
|
||||
pub(crate) fn plan_type_display_name(plan_type: PlanType) -> String {
|
||||
if plan_type.is_team_like() {
|
||||
"Business".to_string()
|
||||
} else if plan_type.is_business_like() {
|
||||
"Enterprise".to_string()
|
||||
} else {
|
||||
title_case(format!("{plan_type:?}").as_str())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn format_tokens_compact(value: i64) -> String {
|
||||
let value = value.max(0);
|
||||
if value == 0 {
|
||||
@@ -158,3 +169,42 @@ pub(crate) fn format_reset_timestamp(dt: DateTime<Local>, captured_at: DateTime<
|
||||
format!("{time} on {}", dt.format("%-d %b"))
|
||||
}
|
||||
}
|
||||
|
||||
fn title_case(s: &str) -> String {
|
||||
if s.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut chars = s.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return String::new();
|
||||
};
|
||||
let rest = chars.as_str().to_ascii_lowercase();
|
||||
first.to_uppercase().collect::<String>() + &rest
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn plan_type_display_name_remaps_display_labels() {
|
||||
let cases = [
|
||||
(PlanType::Free, "Free"),
|
||||
(PlanType::Go, "Go"),
|
||||
(PlanType::Plus, "Plus"),
|
||||
(PlanType::Pro, "Pro"),
|
||||
(PlanType::Team, "Business"),
|
||||
(PlanType::SelfServeBusinessUsageBased, "Business"),
|
||||
(PlanType::Business, "Enterprise"),
|
||||
(PlanType::EnterpriseCbpUsageBased, "Enterprise"),
|
||||
(PlanType::Enterprise, "Enterprise"),
|
||||
(PlanType::Edu, "Edu"),
|
||||
(PlanType::Unknown, "Unknown"),
|
||||
];
|
||||
|
||||
for (plan_type, expected) in cases {
|
||||
assert_eq!(plan_type_display_name(plan_type), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(crate) use card::new_status_output;
|
||||
pub(crate) use card::new_status_output_with_rate_limits;
|
||||
pub(crate) use helpers::format_directory_display;
|
||||
pub(crate) use helpers::format_tokens_compact;
|
||||
pub(crate) use helpers::plan_type_display_name;
|
||||
pub(crate) use rate_limits::RateLimitSnapshotDisplay;
|
||||
pub(crate) use rate_limits::RateLimitWindowDisplay;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -60,11 +60,13 @@ pub(crate) fn get_tooltip(plan: Option<PlanType>, fast_mode_enabled: bool) -> Op
|
||||
// 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) => {
|
||||
Some(plan_type)
|
||||
if matches!(
|
||||
plan_type,
|
||||
PlanType::Plus | PlanType::Enterprise | PlanType::Pro
|
||||
) || plan_type.is_team_like()
|
||||
|| plan_type.is_business_like() =>
|
||||
{
|
||||
return Some(pick_paid_tooltip(&mut rng, fast_mode_enabled).to_string());
|
||||
}
|
||||
Some(PlanType::Go) | Some(PlanType::Free) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ from .v2_all import ContextCompactedNotification
|
||||
from .v2_all import DeprecationNoticeNotification
|
||||
from .v2_all import ErrorNotification
|
||||
from .v2_all import FileChangeOutputDeltaNotification
|
||||
from .v2_all import FsChangedNotification
|
||||
from .v2_all import FuzzyFileSearchSessionCompletedNotification
|
||||
from .v2_all import FuzzyFileSearchSessionUpdatedNotification
|
||||
from .v2_all import HookCompletedNotification
|
||||
@@ -26,6 +27,7 @@ from .v2_all import ItemGuardianApprovalReviewCompletedNotification
|
||||
from .v2_all import ItemGuardianApprovalReviewStartedNotification
|
||||
from .v2_all import ItemStartedNotification
|
||||
from .v2_all import McpServerOauthLoginCompletedNotification
|
||||
from .v2_all import McpServerStatusUpdatedNotification
|
||||
from .v2_all import McpToolCallProgressNotification
|
||||
from .v2_all import ModelReroutedNotification
|
||||
from .v2_all import PlanDeltaNotification
|
||||
@@ -43,6 +45,7 @@ from .v2_all import ThreadRealtimeErrorNotification
|
||||
from .v2_all import ThreadRealtimeItemAddedNotification
|
||||
from .v2_all import ThreadRealtimeOutputAudioDeltaNotification
|
||||
from .v2_all import ThreadRealtimeStartedNotification
|
||||
from .v2_all import ThreadRealtimeTranscriptUpdatedNotification
|
||||
from .v2_all import ThreadStartedNotification
|
||||
from .v2_all import ThreadStatusChangedNotification
|
||||
from .v2_all import ThreadTokenUsageUpdatedNotification
|
||||
@@ -63,6 +66,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"configWarning": ConfigWarningNotification,
|
||||
"deprecationNotice": DeprecationNoticeNotification,
|
||||
"error": ErrorNotification,
|
||||
"fs/changed": FsChangedNotification,
|
||||
"fuzzyFileSearch/sessionCompleted": FuzzyFileSearchSessionCompletedNotification,
|
||||
"fuzzyFileSearch/sessionUpdated": FuzzyFileSearchSessionUpdatedNotification,
|
||||
"hook/completed": HookCompletedNotification,
|
||||
@@ -81,6 +85,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"item/reasoning/textDelta": ReasoningTextDeltaNotification,
|
||||
"item/started": ItemStartedNotification,
|
||||
"mcpServer/oauthLogin/completed": McpServerOauthLoginCompletedNotification,
|
||||
"mcpServer/startupStatus/updated": McpServerStatusUpdatedNotification,
|
||||
"model/rerouted": ModelReroutedNotification,
|
||||
"serverRequest/resolved": ServerRequestResolvedNotification,
|
||||
"skills/changed": SkillsChangedNotification,
|
||||
@@ -93,6 +98,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
|
||||
"thread/realtime/itemAdded": ThreadRealtimeItemAddedNotification,
|
||||
"thread/realtime/outputAudio/delta": ThreadRealtimeOutputAudioDeltaNotification,
|
||||
"thread/realtime/started": ThreadRealtimeStartedNotification,
|
||||
"thread/realtime/transcriptUpdated": ThreadRealtimeTranscriptUpdatedNotification,
|
||||
"thread/started": ThreadStartedNotification,
|
||||
"thread/status/changed": ThreadStatusChangedNotification,
|
||||
"thread/tokenUsage/updated": ThreadTokenUsageUpdatedNotification,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user