Revert "Option to Notify Workspace Owner When Usage Limit is Reached" (#17391)

Reverts openai/codex#16969

#sev3-2026-04-10-accountscheckversion-500s-for-openai-workspace-7300
This commit is contained in:
Shijie Rao
2026-04-10 23:33:13 +00:00
committed by GitHub
parent a3be74143a
commit 930e5adb7e
82 changed files with 60 additions and 3233 deletions
+13 -130
View File
@@ -1,10 +1,8 @@
use crate::types::AccountsCheckV4Response;
use crate::types::CodeTaskDetailsResponse;
use crate::types::ConfigFileResponse;
use crate::types::PaginatedListTaskListItem;
use crate::types::RateLimitStatusPayload;
use crate::types::TurnAttemptsSiblingTurnsResponse;
use crate::types::WorkspaceRole;
use anyhow::Result;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_login::CodexAuth;
@@ -13,7 +11,6 @@ use codex_protocol::account::PlanType as AccountPlanType;
use codex_protocol::protocol::CreditsSnapshot;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlSnapshot;
use reqwest::StatusCode;
use reqwest::header::AUTHORIZATION;
use reqwest::header::CONTENT_TYPE;
@@ -23,10 +20,6 @@ use reqwest::header::HeaderValue;
use reqwest::header::USER_AGENT;
use serde::de::DeserializeOwned;
use std::fmt;
use std::time::Duration;
const BACKEND_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const ACCOUNTS_CHECK_V4_VERSION: &str = "v4-2023-04-27";
#[derive(Debug)]
pub enum RequestError {
@@ -266,55 +259,12 @@ impl Client {
PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url),
PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url),
};
let req = self
.http
.get(&url)
.headers(self.headers())
.timeout(BACKEND_REQUEST_TIMEOUT);
let req = self.http.get(&url).headers(self.headers());
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?;
Ok(Self::rate_limit_snapshots_from_payload(payload))
}
pub async fn send_add_credits_nudge_email(&self) -> std::result::Result<(), RequestError> {
let url = match self.path_style {
PathStyle::CodexApi => {
format!(
"{}/api/codex/accounts/send_add_credits_nudge_email",
self.base_url
)
}
PathStyle::ChatGptApi => {
format!("{}/accounts/send_add_credits_nudge_email", self.base_url)
}
};
let req = self
.http
.post(&url)
.headers(self.headers())
.timeout(BACKEND_REQUEST_TIMEOUT);
let _ = self.exec_request_detailed(req, "POST", &url).await?;
Ok(())
}
pub async fn get_current_workspace_role(&self) -> Result<Option<WorkspaceRole>> {
if self.path_style != PathStyle::ChatGptApi {
return Ok(None);
}
let url = format!(
"{}/accounts/check/{ACCOUNTS_CHECK_V4_VERSION}",
self.base_url
);
let req = self
.http
.get(&url)
.headers(self.headers())
.timeout(BACKEND_REQUEST_TIMEOUT);
let (body, ct) = self.exec_request(req, "GET", &url).await?;
let payload: AccountsCheckV4Response = self.decode_json(&url, &ct, &body)?;
Ok(payload.current_workspace_role(self.chatgpt_account_id.as_deref()))
}
pub async fn list_tasks(
&self,
limit: Option<i32>,
@@ -447,23 +397,20 @@ impl Client {
payload: RateLimitStatusPayload,
) -> Vec<RateLimitSnapshot> {
let plan_type = Some(Self::map_plan_type(payload.plan_type));
let spend_control = payload.spend_control.map(|details| *details);
let mut snapshots = vec![Self::make_rate_limit_snapshot(
Some("codex".to_string()),
/*limit_name*/ None,
payload.rate_limit.map(|details| *details),
payload.credits.map(|details| *details),
spend_control,
payload.rate_limit.flatten().map(|details| *details),
payload.credits.flatten().map(|details| *details),
plan_type,
)];
if let Some(additional) = payload.additional_rate_limits {
if let Some(additional) = payload.additional_rate_limits.flatten() {
snapshots.extend(additional.into_iter().map(|details| {
Self::make_rate_limit_snapshot(
Some(details.metered_feature),
Some(details.limit_name),
details.rate_limit.flatten().map(|rate_limit| *rate_limit),
/*credits*/ None,
/*spend_control*/ None,
plan_type,
)
}));
@@ -476,7 +423,6 @@ impl Client {
limit_name: Option<String>,
rate_limit: Option<crate::types::RateLimitStatusDetails>,
credits: Option<crate::types::CreditStatusDetails>,
spend_control: Option<crate::types::SpendControlStatusDetails>,
plan_type: Option<AccountPlanType>,
) -> RateLimitSnapshot {
let (primary, secondary) = match rate_limit {
@@ -492,7 +438,6 @@ impl Client {
primary,
secondary,
credits: Self::map_credits(credits),
spend_control: Self::map_spend_control(spend_control),
plan_type,
}
}
@@ -522,15 +467,6 @@ impl Client {
})
}
fn map_spend_control(
spend_control: Option<crate::types::SpendControlStatusDetails>,
) -> Option<SpendControlSnapshot> {
let details = spend_control?;
Some(SpendControlSnapshot {
reached: details.reached,
})
}
fn map_plan_type(plan_type: crate::types::PlanType) -> AccountPlanType {
match plan_type {
crate::types::PlanType::Free => AccountPlanType::Free,
@@ -567,6 +503,7 @@ impl Client {
#[cfg(test)]
mod tests {
use super::*;
use codex_backend_openapi_models::models::AdditionalRateLimitDetails;
use pretty_assertions::assert_eq;
#[test]
@@ -585,7 +522,7 @@ mod tests {
fn usage_payload_maps_primary_and_additional_rate_limits() {
let payload = RateLimitStatusPayload {
plan_type: crate::types::PlanType::Pro,
rate_limit: Some(Box::new(crate::types::RateLimitStatusDetails {
rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails {
primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot {
used_percent: 42,
limit_window_seconds: 300,
@@ -599,8 +536,8 @@ mod tests {
reset_at: 456,
}))),
..Default::default()
})),
additional_rate_limits: Some(vec![crate::types::AdditionalRateLimitDetails {
}))),
additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails {
limit_name: "codex_other".to_string(),
metered_feature: "codex_other".to_string(),
rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails {
@@ -613,16 +550,13 @@ mod tests {
secondary_window: None,
..Default::default()
}))),
}]),
credits: Some(Box::new(crate::types::CreditStatusDetails {
}])),
credits: Some(Some(Box::new(crate::types::CreditStatusDetails {
has_credits: true,
unlimited: false,
balance: Some(Some("9.99".to_string())),
..Default::default()
})),
spend_control: Some(Box::new(crate::types::SpendControlStatusDetails {
reached: true,
})),
}))),
};
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
@@ -646,10 +580,6 @@ mod tests {
balance: Some("9.99".to_string()),
})
);
assert_eq!(
snapshots[0].spend_control,
Some(SpendControlSnapshot { reached: true })
);
assert_eq!(snapshots[0].plan_type, Some(AccountPlanType::Pro));
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
@@ -659,7 +589,6 @@ mod tests {
Some(70.0)
);
assert_eq!(snapshots[1].credits, None);
assert_eq!(snapshots[1].spend_control, None);
assert_eq!(snapshots[1].plan_type, Some(AccountPlanType::Pro));
}
@@ -668,13 +597,12 @@ mod tests {
let payload = RateLimitStatusPayload {
plan_type: crate::types::PlanType::Plus,
rate_limit: None,
additional_rate_limits: Some(vec![crate::types::AdditionalRateLimitDetails {
additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails {
limit_name: "codex_other".to_string(),
metered_feature: "codex_other".to_string(),
rate_limit: None,
}]),
}])),
credits: None,
spend_control: None,
};
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
@@ -682,7 +610,6 @@ mod tests {
assert_eq!(snapshots[0].limit_id.as_deref(), Some("codex"));
assert_eq!(snapshots[0].limit_name, None);
assert_eq!(snapshots[0].primary, None);
assert_eq!(snapshots[0].spend_control, None);
assert_eq!(snapshots[1].limit_id.as_deref(), Some("codex_other"));
assert_eq!(snapshots[1].limit_name.as_deref(), Some("codex_other"));
}
@@ -700,7 +627,6 @@ mod tests {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
},
RateLimitSnapshot {
@@ -713,7 +639,6 @@ mod tests {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
},
];
@@ -725,46 +650,4 @@ mod tests {
.unwrap_or_else(|| snapshots[0].clone());
assert_eq!(preferred.limit_id.as_deref(), Some("codex"));
}
#[test]
fn add_credits_nudge_email_uses_expected_paths() {
let codex_api = Client::new("https://example.com").expect("codex api client");
assert_eq!(
match codex_api.path_style {
PathStyle::CodexApi => format!(
"{}/api/codex/accounts/send_add_credits_nudge_email",
codex_api.base_url
),
PathStyle::ChatGptApi => unreachable!("plain host should use codex api paths"),
},
"https://example.com/api/codex/accounts/send_add_credits_nudge_email"
);
let chatgpt_api = Client::new("https://chatgpt.com").expect("chatgpt backend api client");
assert_eq!(
match chatgpt_api.path_style {
PathStyle::CodexApi => unreachable!("chatgpt host should use backend-api paths"),
PathStyle::ChatGptApi => format!(
"{}/accounts/send_add_credits_nudge_email",
chatgpt_api.base_url
),
},
"https://chatgpt.com/backend-api/accounts/send_add_credits_nudge_email"
);
}
#[test]
fn current_workspace_role_uses_expected_path() {
let chatgpt_api = Client::new("https://chatgpt.com").expect("chatgpt backend api client");
assert_eq!(
match chatgpt_api.path_style {
PathStyle::CodexApi => unreachable!("chatgpt host should use backend-api paths"),
PathStyle::ChatGptApi => format!(
"{}/accounts/check/{ACCOUNTS_CHECK_V4_VERSION}",
chatgpt_api.base_url
),
},
"https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27"
);
}
}
-2
View File
@@ -3,11 +3,9 @@ pub(crate) mod types;
pub use client::Client;
pub use client::RequestError;
pub use types::AccountsCheckV4Response;
pub use types::CodeTaskDetailsResponse;
pub use types::CodeTaskDetailsResponseExt;
pub use types::ConfigFileResponse;
pub use types::PaginatedListTaskListItem;
pub use types::TaskListItem;
pub use types::TurnAttemptsSiblingTurnsResponse;
pub use types::WorkspaceRole;
+1 -176
View File
@@ -1,115 +1,17 @@
pub use codex_backend_openapi_models::models::AdditionalRateLimitDetails;
pub use codex_backend_openapi_models::models::ConfigFileResponse;
pub use codex_backend_openapi_models::models::CreditStatusDetails;
pub use codex_backend_openapi_models::models::PaginatedListTaskListItem;
pub use codex_backend_openapi_models::models::PlanType;
pub use codex_backend_openapi_models::models::RateLimitStatusDetails;
pub use codex_backend_openapi_models::models::RateLimitStatusPayload;
pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot;
pub use codex_backend_openapi_models::models::TaskListItem;
use serde::Deserialize;
use serde::Serialize;
use serde::de::Deserializer;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WorkspaceRole {
AccountOwner,
AccountAdmin,
StandardUser,
}
impl WorkspaceRole {
pub fn from_api_str(value: &str) -> Option<Self> {
match value {
"account-owner" | "account_owner" => Some(Self::AccountOwner),
"account-admin" | "account_admin" => Some(Self::AccountAdmin),
"standard-user" | "standard_user" | "member" => Some(Self::StandardUser),
_ => None,
}
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct AccountsCheckV4Response {
#[serde(default)]
pub accounts: HashMap<String, AccountsCheckV4AccountItem>,
#[serde(default)]
pub account_ordering: Vec<String>,
}
impl AccountsCheckV4Response {
pub fn current_workspace_role(
&self,
current_account_id: Option<&str>,
) -> Option<WorkspaceRole> {
let account = if let Some(account_id) = current_account_id {
self.accounts.get(account_id)?
} else {
self.account_ordering
.iter()
.find_map(|account_id| self.accounts.get(account_id))
.or_else(|| {
if self.accounts.len() == 1 {
self.accounts.values().next()
} else {
None
}
})?
};
account
.account
.as_ref()
.and_then(|account| account.account_user_role.as_deref())
.and_then(WorkspaceRole::from_api_str)
}
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct AccountsCheckV4AccountItem {
#[serde(default)]
pub account: Option<AccountsCheckV4Account>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct AccountsCheckV4Account {
#[serde(default, rename = "account_user_role")]
pub account_user_role: Option<String>,
}
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct RateLimitStatusPayload {
#[serde(rename = "plan_type")]
pub plan_type: PlanType,
#[serde(
rename = "rate_limit",
default,
skip_serializing_if = "Option::is_none"
)]
pub rate_limit: Option<Box<RateLimitStatusDetails>>,
#[serde(rename = "credits", default, skip_serializing_if = "Option::is_none")]
pub credits: Option<Box<CreditStatusDetails>>,
#[serde(
rename = "additional_rate_limits",
default,
skip_serializing_if = "Option::is_none"
)]
pub additional_rate_limits: Option<Vec<AdditionalRateLimitDetails>>,
#[serde(
rename = "spend_control",
default,
skip_serializing_if = "Option::is_none"
)]
pub spend_control: Option<Box<SpendControlStatusDetails>>,
}
#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
pub struct SpendControlStatusDetails {
#[serde(rename = "reached")]
pub reached: bool,
}
/// Hand-rolled models for the Cloud Tasks task-details response.
/// The generated OpenAPI models are pretty bad. This is a half-step
/// towards hand-rolling them.
@@ -318,83 +220,6 @@ impl Turn {
}
}
#[cfg(test)]
mod accounts_check_tests {
use super::AccountsCheckV4Account;
use super::AccountsCheckV4AccountItem;
use super::AccountsCheckV4Response;
use super::WorkspaceRole;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
#[test]
fn current_workspace_role_prefers_current_account_id() {
let response = AccountsCheckV4Response {
accounts: HashMap::from([
(
"workspace-a".to_string(),
AccountsCheckV4AccountItem {
account: Some(AccountsCheckV4Account {
account_user_role: Some("standard-user".to_string()),
}),
},
),
(
"workspace-b".to_string(),
AccountsCheckV4AccountItem {
account: Some(AccountsCheckV4Account {
account_user_role: Some("account-owner".to_string()),
}),
},
),
]),
account_ordering: vec!["workspace-a".to_string(), "workspace-b".to_string()],
};
assert_eq!(
response.current_workspace_role(Some("workspace-b")),
Some(WorkspaceRole::AccountOwner)
);
}
#[test]
fn current_workspace_role_falls_back_to_account_ordering() {
let response = AccountsCheckV4Response {
accounts: HashMap::from([(
"workspace-a".to_string(),
AccountsCheckV4AccountItem {
account: Some(AccountsCheckV4Account {
account_user_role: Some("account_admin".to_string()),
}),
},
)]),
account_ordering: vec!["workspace-a".to_string()],
};
assert_eq!(
response.current_workspace_role(/*current_account_id*/ None),
Some(WorkspaceRole::AccountAdmin)
);
}
#[test]
fn current_workspace_role_does_not_fall_back_when_current_account_missing() {
let response = AccountsCheckV4Response {
accounts: HashMap::from([(
"workspace-a".to_string(),
AccountsCheckV4AccountItem {
account: Some(AccountsCheckV4Account {
account_user_role: Some("account-owner".to_string()),
}),
},
)]),
account_ordering: vec!["workspace-a".to_string()],
};
assert_eq!(response.current_workspace_role(Some("workspace-b")), None);
}
}
impl WorklogMessage {
fn is_assistant(&self) -> bool {
self.author