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 16:33:13 -07:00
committed by GitHub
Unverified
parent a3be74143a
commit 930e5adb7e
82 changed files with 60 additions and 3233 deletions
-11
View File
@@ -1366,16 +1366,6 @@ dependencies = [
"unicode-width 0.1.14",
]
[[package]]
name = "codex-account"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-backend-client",
"codex-login",
"thiserror 2.0.18",
]
[[package]]
name = "codex-analytics"
version = "0.0.0"
@@ -1908,7 +1898,6 @@ dependencies = [
"bm25",
"chrono",
"clap",
"codex-account",
"codex-analytics",
"codex-api",
"codex-app-server-protocol",
-2
View File
@@ -1,6 +1,5 @@
[workspace]
members = [
"account",
"analytics",
"backend-client",
"ansi-escape",
@@ -105,7 +104,6 @@ license = "Apache-2.0"
# Internal
app_test_support = { path = "app-server/tests/common" }
codex-analytics = { path = "analytics" }
codex-account = { path = "account" }
codex-ansi-escape = { path = "ansi-escape" }
codex-api = { path = "codex-api" }
codex-app-server = { path = "app-server" }
-6
View File
@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "account",
crate_name = "codex_account",
)
-19
View File
@@ -1,19 +0,0 @@
[package]
name = "codex-account"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[lib]
name = "codex_account"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-backend-client = { workspace = true }
codex-login = { workspace = true }
thiserror = { workspace = true }
-121
View File
@@ -1,121 +0,0 @@
use codex_backend_client::Client as BackendClient;
use codex_backend_client::RequestError;
use codex_backend_client::WorkspaceRole as BackendWorkspaceRole;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkspaceRole {
AccountOwner,
AccountAdmin,
StandardUser,
}
impl WorkspaceRole {
fn is_workspace_owner(self) -> bool {
matches!(self, Self::AccountOwner | Self::AccountAdmin)
}
}
impl From<BackendWorkspaceRole> for WorkspaceRole {
fn from(value: BackendWorkspaceRole) -> Self {
match value {
BackendWorkspaceRole::AccountOwner => Self::AccountOwner,
BackendWorkspaceRole::AccountAdmin => Self::AccountAdmin,
BackendWorkspaceRole::StandardUser => Self::StandardUser,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct WorkspaceOwnership {
pub workspace_role: Option<WorkspaceRole>,
pub is_workspace_owner: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AddCreditsNudgeEmailStatus {
Sent,
CooldownActive,
}
#[derive(Debug, Error)]
pub enum SendAddCreditsNudgeEmailError {
#[error("codex account authentication required to notify workspace owner")]
AuthRequired,
#[error("chatgpt authentication required to notify workspace owner")]
ChatGptAuthRequired,
#[error("failed to construct backend client: {0}")]
CreateClient(#[from] anyhow::Error),
#[error("failed to notify workspace owner: {0}")]
Request(#[from] RequestError),
}
pub async fn send_add_credits_nudge_email(
chatgpt_base_url: impl Into<String>,
auth_manager: &AuthManager,
) -> Result<AddCreditsNudgeEmailStatus, SendAddCreditsNudgeEmailError> {
let auth = auth_manager
.auth()
.await
.ok_or(SendAddCreditsNudgeEmailError::AuthRequired)?;
send_add_credits_nudge_email_for_auth(chatgpt_base_url, &auth).await
}
pub async fn send_add_credits_nudge_email_for_auth(
chatgpt_base_url: impl Into<String>,
auth: &CodexAuth,
) -> Result<AddCreditsNudgeEmailStatus, SendAddCreditsNudgeEmailError> {
if !auth.is_chatgpt_auth() {
return Err(SendAddCreditsNudgeEmailError::ChatGptAuthRequired);
}
let client = BackendClient::from_auth(chatgpt_base_url, auth)?;
match client.send_add_credits_nudge_email().await {
Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent),
Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => {
Ok(AddCreditsNudgeEmailStatus::CooldownActive)
}
Err(err) => Err(err.into()),
}
}
pub async fn resolve_workspace_role_and_owner_for_auth(
chatgpt_base_url: &str,
auth: Option<&CodexAuth>,
) -> WorkspaceOwnership {
let token_is_workspace_owner = auth.and_then(CodexAuth::is_workspace_owner);
let Some(auth) = auth else {
return WorkspaceOwnership::default();
};
let workspace_role = fetch_current_workspace_role_for_auth(chatgpt_base_url, auth).await;
let is_workspace_owner = workspace_role
.map(WorkspaceRole::is_workspace_owner)
.or(token_is_workspace_owner);
WorkspaceOwnership {
workspace_role,
is_workspace_owner,
}
}
async fn fetch_current_workspace_role_for_auth(
chatgpt_base_url: &str,
auth: &CodexAuth,
) -> Option<WorkspaceRole> {
if !auth.is_chatgpt_auth() {
return None;
}
let client = BackendClient::from_auth(chatgpt_base_url.to_string(), auth).ok()?;
client
.get_current_workspace_role()
.await
.ok()
.flatten()
.map(WorkspaceRole::from)
}
-8
View File
@@ -1287,8 +1287,6 @@ mod tests {
id: request.id,
result: serde_json::to_value(GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: false,
})
.expect("response should serialize"),
@@ -1397,8 +1395,6 @@ mod tests {
id: request.id,
result: serde_json::to_value(GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: false,
})
.expect("response should serialize"),
@@ -1452,8 +1448,6 @@ mod tests {
first_response,
GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: false,
}
);
@@ -1473,8 +1467,6 @@ mod tests {
AccountUpdatedNotification {
auth_mode: None,
plan_type: None,
workspace_role: None,
is_workspace_owner: None,
},
))
.expect("notification should serialize"),
@@ -2562,17 +2562,6 @@
],
"type": "object"
},
"ThreadAddCreditsNudgeEmailParams": {
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadArchiveParams": {
"properties": {
"threadId": {
@@ -3837,30 +3826,6 @@
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/addCreditsNudgeEmail"
],
"title": "Thread/addCreditsNudgeEmailRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAddCreditsNudgeEmailParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/addCreditsNudgeEmailRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -51,12 +51,6 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -66,89 +60,10 @@
"type": "null"
}
]
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AddCreditsNudgeEmailNotification": {
"properties": {
"result": {
"$ref": "#/definitions/AddCreditsNudgeEmailResult"
},
"threadId": {
"type": "string"
}
},
"required": [
"result",
"threadId"
],
"type": "object"
},
"AddCreditsNudgeEmailResult": {
"oneOf": [
{
"properties": {
"status": {
"enum": [
"sent"
],
"title": "SentAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "SentAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"status": {
"enum": [
"cooldownActive"
],
"title": "CooldownActiveAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "CooldownActiveAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"message": {
"type": "string"
},
"status": {
"enum": [
"failed"
],
"type": "string"
}
},
"required": [
"message",
"status"
],
"type": "object"
}
]
},
"AgentMessageDeltaNotification": {
"properties": {
"delta": {
@@ -2157,16 +2072,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -2366,17 +2271,6 @@
"description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.",
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -4161,14 +4055,6 @@
"samplePaths"
],
"type": "object"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
}
},
"description": "Notification sent from the server to the client.",
@@ -4796,26 +4682,6 @@
"title": "Account/rateLimits/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"account/addCreditsNudgeEmail/completed"
],
"title": "Account/addCreditsNudgeEmail/completedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AddCreditsNudgeEmailNotification"
}
},
"required": [
"method",
"params"
],
"title": "Account/addCreditsNudgeEmail/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -482,30 +482,6 @@
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/addCreditsNudgeEmail"
],
"title": "Thread/addCreditsNudgeEmailRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadAddCreditsNudgeEmailParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/addCreditsNudgeEmailRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4058,26 +4034,6 @@
"title": "Account/rateLimits/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"account/addCreditsNudgeEmail/completed"
],
"title": "Account/addCreditsNudgeEmail/completedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/AddCreditsNudgeEmailNotification"
}
},
"required": [
"method",
"params"
],
"title": "Account/addCreditsNudgeEmail/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -4965,12 +4921,6 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -4980,92 +4930,11 @@
"type": "null"
}
]
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/v2/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",
"type": "object"
},
"AddCreditsNudgeEmailNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"result": {
"$ref": "#/definitions/v2/AddCreditsNudgeEmailResult"
},
"threadId": {
"type": "string"
}
},
"required": [
"result",
"threadId"
],
"title": "AddCreditsNudgeEmailNotification",
"type": "object"
},
"AddCreditsNudgeEmailResult": {
"oneOf": [
{
"properties": {
"status": {
"enum": [
"sent"
],
"title": "SentAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "SentAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"status": {
"enum": [
"cooldownActive"
],
"title": "CooldownActiveAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "CooldownActiveAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"message": {
"type": "string"
},
"status": {
"enum": [
"failed"
],
"type": "string"
}
},
"required": [
"message",
"status"
],
"type": "object"
}
]
},
"AgentMessageDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -8149,24 +8018,8 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"requiresOpenaiAuth": {
"type": "boolean"
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/v2/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -10570,16 +10423,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/v2/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -12403,17 +12246,6 @@
"title": "SkillsListResponse",
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -12705,24 +12537,6 @@
],
"type": "string"
},
"ThreadAddCreditsNudgeEmailParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadAddCreditsNudgeEmailParams",
"type": "object"
},
"ThreadAddCreditsNudgeEmailResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadAddCreditsNudgeEmailResponse",
"type": "object"
},
"ThreadArchiveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -15648,14 +15462,6 @@
"title": "WindowsWorldWritableWarningNotification",
"type": "object"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
},
"WriteStatus": {
"enum": [
"ok",
@@ -100,12 +100,6 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -115,92 +109,11 @@
"type": "null"
}
]
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",
"type": "object"
},
"AddCreditsNudgeEmailNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"result": {
"$ref": "#/definitions/AddCreditsNudgeEmailResult"
},
"threadId": {
"type": "string"
}
},
"required": [
"result",
"threadId"
],
"title": "AddCreditsNudgeEmailNotification",
"type": "object"
},
"AddCreditsNudgeEmailResult": {
"oneOf": [
{
"properties": {
"status": {
"enum": [
"sent"
],
"title": "SentAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "SentAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"status": {
"enum": [
"cooldownActive"
],
"title": "CooldownActiveAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "CooldownActiveAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"message": {
"type": "string"
},
"status": {
"enum": [
"failed"
],
"type": "string"
}
},
"required": [
"message",
"status"
],
"type": "object"
}
]
},
"AgentMessageDeltaNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -1151,30 +1064,6 @@
"title": "Thread/shellCommandRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/addCreditsNudgeEmail"
],
"title": "Thread/addCreditsNudgeEmailRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadAddCreditsNudgeEmailParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/addCreditsNudgeEmailRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4888,24 +4777,8 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"requiresOpenaiAuth": {
"type": "boolean"
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -7353,16 +7226,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -9372,26 +9235,6 @@
"title": "Account/rateLimits/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"account/addCreditsNudgeEmail/completed"
],
"title": "Account/addCreditsNudgeEmail/completedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/AddCreditsNudgeEmailNotification"
}
},
"required": [
"method",
"params"
],
"title": "Account/addCreditsNudgeEmail/completedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -10258,17 +10101,6 @@
"title": "SkillsListResponse",
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -10560,24 +10392,6 @@
],
"type": "string"
},
"ThreadAddCreditsNudgeEmailParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadAddCreditsNudgeEmailParams",
"type": "object"
},
"ThreadAddCreditsNudgeEmailResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadAddCreditsNudgeEmailResponse",
"type": "object"
},
"ThreadArchiveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -13503,14 +13317,6 @@
"title": "WindowsWorldWritableWarningNotification",
"type": "object"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
},
"WriteStatus": {
"enum": [
"ok",
@@ -91,16 +91,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -130,17 +120,6 @@
"usedPercent"
],
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
}
},
"properties": {
@@ -42,14 +42,6 @@
"unknown"
],
"type": "string"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
}
},
"properties": {
@@ -63,12 +55,6 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"planType": {
"anyOf": [
{
@@ -78,16 +64,6 @@
"type": "null"
}
]
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"title": "AccountUpdatedNotification",
@@ -1,73 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AddCreditsNudgeEmailResult": {
"oneOf": [
{
"properties": {
"status": {
"enum": [
"sent"
],
"title": "SentAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "SentAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"status": {
"enum": [
"cooldownActive"
],
"title": "CooldownActiveAddCreditsNudgeEmailResultStatus",
"type": "string"
}
},
"required": [
"status"
],
"title": "CooldownActiveAddCreditsNudgeEmailResult",
"type": "object"
},
{
"properties": {
"message": {
"type": "string"
},
"status": {
"enum": [
"failed"
],
"type": "string"
}
},
"required": [
"message",
"status"
],
"type": "object"
}
]
}
},
"properties": {
"result": {
"$ref": "#/definitions/AddCreditsNudgeEmailResult"
},
"threadId": {
"type": "string"
}
},
"required": [
"result",
"threadId"
],
"title": "AddCreditsNudgeEmailNotification",
"type": "object"
}
@@ -91,16 +91,6 @@
"type": "null"
}
]
},
"spendControl": {
"anyOf": [
{
"$ref": "#/definitions/SpendControlSnapshot"
},
{
"type": "null"
}
]
}
},
"type": "object"
@@ -130,17 +120,6 @@
"usedPercent"
],
"type": "object"
},
"SpendControlSnapshot": {
"properties": {
"reached": {
"type": "boolean"
}
},
"required": [
"reached"
],
"type": "object"
}
},
"properties": {
@@ -60,14 +60,6 @@
"unknown"
],
"type": "string"
},
"WorkspaceRole": {
"enum": [
"account-owner",
"account-admin",
"standard-user"
],
"type": "string"
}
},
"properties": {
@@ -81,24 +73,8 @@
}
]
},
"isWorkspaceOwner": {
"type": [
"boolean",
"null"
]
},
"requiresOpenaiAuth": {
"type": "boolean"
},
"workspaceRole": {
"anyOf": [
{
"$ref": "#/definitions/WorkspaceRole"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -1,13 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadAddCreditsNudgeEmailParams",
"type": "object"
}
@@ -1,5 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadAddCreditsNudgeEmailResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -6,7 +6,6 @@ import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearc
import type { AccountLoginCompletedNotification } from "./v2/AccountLoginCompletedNotification";
import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimitsUpdatedNotification";
import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification";
import type { AddCreditsNudgeEmailNotification } from "./v2/AddCreditsNudgeEmailNotification";
import type { AgentMessageDeltaNotification } from "./v2/AgentMessageDeltaNotification";
import type { AppListUpdatedNotification } from "./v2/AppListUpdatedNotification";
import type { CommandExecOutputDeltaNotification } from "./v2/CommandExecOutputDeltaNotification";
@@ -59,4 +58,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "account/addCreditsNudgeEmail/completed", "params": AddCreditsNudgeEmailNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
@@ -3,6 +3,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AuthMode } from "../AuthMode";
import type { PlanType } from "../PlanType";
import type { WorkspaceRole } from "./WorkspaceRole";
export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, workspaceRole: WorkspaceRole | null, isWorkspaceOwner: boolean | null, };
export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, };
@@ -1,6 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AddCreditsNudgeEmailResult } from "./AddCreditsNudgeEmailResult";
export type AddCreditsNudgeEmailNotification = { threadId: string, result: AddCreditsNudgeEmailResult, };
@@ -1,5 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AddCreditsNudgeEmailResult = { "status": "sent" } | { "status": "cooldownActive" } | { "status": "failed", message: string, };
@@ -2,6 +2,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Account } from "./Account";
import type { WorkspaceRole } from "./WorkspaceRole";
export type GetAccountResponse = { account: Account | null, workspaceRole: WorkspaceRole | null, isWorkspaceOwner: boolean | null, requiresOpenaiAuth: boolean, };
export type GetAccountResponse = { account: Account | null, requiresOpenaiAuth: boolean, };
@@ -4,6 +4,5 @@
import type { PlanType } from "../PlanType";
import type { CreditsSnapshot } from "./CreditsSnapshot";
import type { RateLimitWindow } from "./RateLimitWindow";
import type { SpendControlSnapshot } from "./SpendControlSnapshot";
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, spendControl: SpendControlSnapshot | null, planType: PlanType | null, };
export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, planType: PlanType | null, };
@@ -1,5 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SpendControlSnapshot = { reached: boolean, };
@@ -1,5 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadAddCreditsNudgeEmailParams = { threadId: string, };
@@ -1,5 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadAddCreditsNudgeEmailResponse = Record<string, never>;
@@ -1,5 +0,0 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type WorkspaceRole = "account-owner" | "account-admin" | "standard-user";
@@ -4,8 +4,6 @@ export type { Account } from "./Account";
export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification";
export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification";
export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { AddCreditsNudgeEmailNotification } from "./AddCreditsNudgeEmailNotification";
export type { AddCreditsNudgeEmailResult } from "./AddCreditsNudgeEmailResult";
export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions";
export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions";
export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
@@ -268,15 +266,12 @@ export type { SkillsListEntry } from "./SkillsListEntry";
export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd";
export type { SkillsListParams } from "./SkillsListParams";
export type { SkillsListResponse } from "./SkillsListResponse";
export type { SpendControlSnapshot } from "./SpendControlSnapshot";
export type { TerminalInteractionNotification } from "./TerminalInteractionNotification";
export type { TextElement } from "./TextElement";
export type { TextPosition } from "./TextPosition";
export type { TextRange } from "./TextRange";
export type { Thread } from "./Thread";
export type { ThreadActiveFlag } from "./ThreadActiveFlag";
export type { ThreadAddCreditsNudgeEmailParams } from "./ThreadAddCreditsNudgeEmailParams";
export type { ThreadAddCreditsNudgeEmailResponse } from "./ThreadAddCreditsNudgeEmailResponse";
export type { ThreadArchiveParams } from "./ThreadArchiveParams";
export type { ThreadArchiveResponse } from "./ThreadArchiveResponse";
export type { ThreadArchivedNotification } from "./ThreadArchivedNotification";
@@ -358,5 +353,4 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams";
export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse";
export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification";
export type { WorkspaceRole } from "./WorkspaceRole";
export type { WriteStatus } from "./WriteStatus";
@@ -296,10 +296,6 @@ client_request_definitions! {
params: v2::ThreadShellCommandParams,
response: v2::ThreadShellCommandResponse,
},
ThreadAddCreditsNudgeEmail => "thread/addCreditsNudgeEmail" {
params: v2::ThreadAddCreditsNudgeEmailParams,
response: v2::ThreadAddCreditsNudgeEmailResponse,
},
#[experimental("thread/backgroundTerminals/clean")]
ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" {
params: v2::ThreadBackgroundTerminalsCleanParams,
@@ -995,7 +991,6 @@ server_notification_definitions! {
McpServerStatusUpdated => "mcpServer/startupStatus/updated" (v2::McpServerStatusUpdatedNotification),
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
AddCreditsNudgeEmailCompleted => "account/addCreditsNudgeEmail/completed" (v2::AddCreditsNudgeEmailNotification),
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
FsChanged => "fs/changed" (v2::FsChangedNotification),
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
@@ -83,7 +83,6 @@ use codex_protocol::protocol::SkillInterface as CoreSkillInterface;
use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata;
use codex_protocol::protocol::SkillScope as CoreSkillScope;
use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency;
use codex_protocol::protocol::SpendControlSnapshot as CoreSpendControlSnapshot;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo;
@@ -1748,27 +1747,11 @@ pub struct GetAccountParams {
pub refresh_token: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum WorkspaceRole {
#[serde(rename = "account-owner")]
#[ts(rename = "account-owner")]
AccountOwner,
#[serde(rename = "account-admin")]
#[ts(rename = "account-admin")]
AccountAdmin,
#[serde(rename = "standard-user")]
#[ts(rename = "standard-user")]
StandardUser,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct GetAccountResponse {
pub account: Option<Account>,
pub workspace_role: Option<WorkspaceRole>,
pub is_workspace_owner: Option<bool>,
pub requires_openai_auth: bool,
}
@@ -3047,18 +3030,6 @@ pub struct ThreadShellCommandParams {
#[ts(export_to = "v2/")]
pub struct ThreadShellCommandResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAddCreditsNudgeEmailParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadAddCreditsNudgeEmailResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3696,8 +3667,6 @@ pub struct Thread {
pub struct AccountUpdatedNotification {
pub auth_mode: Option<AuthMode>,
pub plan_type: Option<PlanType>,
pub workspace_role: Option<WorkspaceRole>,
pub is_workspace_owner: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -6304,24 +6273,6 @@ pub struct AccountRateLimitsUpdatedNotification {
pub rate_limits: RateLimitSnapshot,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct AddCreditsNudgeEmailNotification {
pub thread_id: String,
pub result: AddCreditsNudgeEmailResult,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(tag = "status", rename_all = "camelCase")]
#[ts(tag = "status", rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum AddCreditsNudgeEmailResult {
Sent,
CooldownActive,
Failed { message: String },
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -6331,7 +6282,6 @@ pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub spend_control: Option<SpendControlSnapshot>,
pub plan_type: Option<PlanType>,
}
@@ -6343,7 +6293,6 @@ impl From<CoreRateLimitSnapshot> for RateLimitSnapshot {
primary: value.primary.map(RateLimitWindow::from),
secondary: value.secondary.map(RateLimitWindow::from),
credits: value.credits.map(CreditsSnapshot::from),
spend_control: value.spend_control.map(SpendControlSnapshot::from),
plan_type: value.plan_type,
}
}
@@ -6389,21 +6338,6 @@ impl From<CoreCreditsSnapshot> for CreditsSnapshot {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SpendControlSnapshot {
pub reached: bool,
}
impl From<CoreSpendControlSnapshot> for SpendControlSnapshot {
fn from(value: CoreSpendControlSnapshot) -> Self {
Self {
reached: value.reached,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+11 -24
View File
@@ -147,7 +147,6 @@ Example with notification opt-out:
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`.
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
- `thread/addCreditsNudgeEmail` — ask the backend to notify the workspace owner that the thread hit a usage limit; returns `{}` immediately and emits `account/addCreditsNudgeEmail/completed` with the result.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/rollback` — drop the last N turns from the agents in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
@@ -458,15 +457,6 @@ If the thread does not already have an active turn, the server starts a standalo
{ "id": 26, "result": {} }
```
### Example: Notify Workspace Owner About Usage Limit
Use `thread/addCreditsNudgeEmail` when a usage-limit prompt needs to notify the thread's workspace owner. The request returns immediately with `{}`. The final delivery result is emitted separately as `account/addCreditsNudgeEmail/completed` for the same `threadId`.
```json
{ "method": "thread/addCreditsNudgeEmail", "id": 27, "params": { "threadId": "thr_b" } }
{ "id": 27, "result": {} }
```
### Example: Start a turn (send user input)
Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions:
@@ -1367,19 +1357,19 @@ The JSON-RPC auth/account surface exposes request/response methods plus server-i
### Authentication modes
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, the current `workspaceRole` when live account metadata can be fetched, and the derived `isWorkspaceOwner` flag. The same cached account state can be read from `account/read`.
Codex supports these authentication modes. The current mode is surfaced in `account/updated` (`authMode`), which also includes the current ChatGPT `planType` when available, and can be inferred from `account/read`.
- **API key (`apiKey`)**: Caller supplies an OpenAI API key via `account/login/start` with `type: "apiKey"`. The API key is saved and used for API requests.
- **ChatGPT managed (`chatgpt`)** (recommended): Codex owns the ChatGPT OAuth flow and refresh tokens. Start via `account/login/start` with `type: "chatgpt"` for the browser flow or `type: "chatgptDeviceCode"` for device code; Codex persists tokens to disk and refreshes them automatically.
### API Overview
- `account/read` — fetch cached current account info; optionally refresh tokens. ChatGPT workspace role is refreshed in the background and delivered by `account/updated` so this request does not wait for live account metadata.
- `account/read` — fetch current account info; optionally refresh tokens.
- `account/login/start` — begin login (`apiKey`, `chatgpt`, `chatgptDeviceCode`).
- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error).
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType`, `workspaceRole`, and `isWorkspaceOwner`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change.
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
@@ -1396,19 +1386,16 @@ Request:
Response examples:
```json
{ "id": 1, "result": { "account": null, "workspaceRole": null, "isWorkspaceOwner": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models)
{ "id": 1, "result": { "account": null, "workspaceRole": null, "isWorkspaceOwner": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
{ "id": 1, "result": { "account": { "type": "apiKey" }, "workspaceRole": null, "isWorkspaceOwner": null, "requiresOpenaiAuth": true } }
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "workspaceRole": null, "isWorkspaceOwner": true, "requiresOpenaiAuth": true } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "pro", "workspaceRole": "account-admin", "isWorkspaceOwner": true } } // emitted when live workspace metadata arrives
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models)
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } }
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } }
```
Field notes:
- `refreshToken` (bool): set `true` to force a token refresh.
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
- `workspaceRole` is a nullable live account role from ChatGPT account metadata: `account-owner`, `account-admin`, or `standard-user`. It is normally delivered asynchronously in `account/updated`; clients should treat `null` from `account/read` as "not known yet".
- `isWorkspaceOwner` is a nullable convenience flag. When `workspaceRole` is available, owners and admins map to `true` and standard users map to `false`; otherwise the server may fall back to the managed-account token's workspace-owner claim.
### 2) Log in with an API key
@@ -1427,7 +1414,7 @@ Field notes:
3. Notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "apikey", "planType": null, "workspaceRole": null, "isWorkspaceOwner": null } }
{ "method": "account/updated", "params": { "authMode": "apikey", "planType": null } }
```
### 3) Log in with ChatGPT (browser flow)
@@ -1441,7 +1428,7 @@ Field notes:
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus", "workspaceRole": "account-owner", "isWorkspaceOwner": true } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
```
### 4) Log in with ChatGPT (device code flow)
@@ -1455,7 +1442,7 @@ Field notes:
3. Wait for notifications:
```json
{ "method": "account/login/completed", "params": { "loginId": "<uuid>", "success": true, "error": null } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus", "workspaceRole": "account-owner", "isWorkspaceOwner": true } }
{ "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } }
```
### 5) Cancel a ChatGPT login
@@ -1470,7 +1457,7 @@ Field notes:
```json
{ "method": "account/logout", "id": 6 }
{ "id": 6, "result": {} }
{ "method": "account/updated", "params": { "authMode": null, "planType": null, "workspaceRole": null, "isWorkspaceOwner": null } }
{ "method": "account/updated", "params": { "authMode": null, "planType": null } }
```
### 7) Rate limits (ChatGPT)
@@ -13,8 +13,6 @@ use crate::thread_state::resolve_server_request_on_thread_listener;
use crate::thread_status::ThreadWatchActiveGuard;
use crate::thread_status::ThreadWatchManager;
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailResult;
use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::ApplyPatchApprovalParams;
@@ -119,7 +117,6 @@ use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynam
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::items::parse_hook_prompt_message;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::AddCreditsNudgeEmailStatus as CoreAddCreditsNudgeEmailStatus;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
@@ -226,26 +223,6 @@ pub(crate) async fn apply_bespoke_event_handling(
)
.await;
}
EventMsg::AddCreditsNudgeEmailResponse(event) => {
if let ApiVersion::V2 = api_version {
let result = match event.result {
Ok(CoreAddCreditsNudgeEmailStatus::Sent) => AddCreditsNudgeEmailResult::Sent,
Ok(CoreAddCreditsNudgeEmailStatus::CooldownActive) => {
AddCreditsNudgeEmailResult::CooldownActive
}
Err(message) => AddCreditsNudgeEmailResult::Failed { message },
};
let notification = AddCreditsNudgeEmailNotification {
thread_id: conversation_id.to_string(),
result,
};
outgoing
.send_server_notification(ServerNotification::AddCreditsNudgeEmailCompleted(
notification,
))
.await;
}
}
EventMsg::SkillsUpdateAvailable => {
if let ApiVersion::V2 = api_version {
outgoing
@@ -4062,7 +4039,6 @@ mod tests {
unlimited: false,
balance: Some("5".to_string()),
}),
spend_control: None,
plan_type: None,
};
@@ -116,8 +116,6 @@ use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadArchivedNotification;
@@ -186,7 +184,6 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_app_server_protocol::WindowsSandboxSetupStartParams;
use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_app_server_protocol::WorkspaceRole;
use codex_app_server_protocol::build_turns_from_rollout_items;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::Client as BackendClient;
@@ -203,7 +200,6 @@ use codex_core::SteerInputError;
use codex_core::ThreadConfigSnapshot;
use codex_core::ThreadManager;
use codex_core::ThreadSortKey as CoreThreadSortKey;
use codex_core::WorkspaceRole as CoreWorkspaceRole;
use codex_core::append_thread_name;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
@@ -490,96 +486,6 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) log_db: Option<LogDbLayer>,
}
async fn resolve_workspace_role_and_owner_for_auth(
chatgpt_base_url: &str,
auth: Option<&CodexAuth>,
) -> (Option<WorkspaceRole>, Option<bool>) {
let ownership =
codex_core::resolve_workspace_role_and_owner_for_auth(chatgpt_base_url, auth).await;
(
ownership.workspace_role.map(workspace_role_to_v2),
ownership.is_workspace_owner,
)
}
fn workspace_role_to_v2(role: CoreWorkspaceRole) -> WorkspaceRole {
match role {
CoreWorkspaceRole::AccountOwner => WorkspaceRole::AccountOwner,
CoreWorkspaceRole::AccountAdmin => WorkspaceRole::AccountAdmin,
CoreWorkspaceRole::StandardUser => WorkspaceRole::StandardUser,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct AuthIdentity {
auth_mode: AuthMode,
account_id: Option<String>,
account_email: Option<String>,
chatgpt_user_id: Option<String>,
}
fn auth_identity(auth: &CodexAuth) -> AuthIdentity {
AuthIdentity {
auth_mode: auth.api_auth_mode(),
account_id: auth.get_account_id(),
account_email: auth.get_account_email(),
chatgpt_user_id: auth.get_chatgpt_user_id(),
}
}
fn cached_workspace_role_and_owner_for_auth(
auth: Option<&CodexAuth>,
) -> (Option<WorkspaceRole>, Option<bool>) {
(None, auth.and_then(CodexAuth::is_workspace_owner))
}
fn account_updated_notification_for_auth(
auth: Option<&CodexAuth>,
workspace_role: Option<WorkspaceRole>,
is_workspace_owner: Option<bool>,
) -> AccountUpdatedNotification {
AccountUpdatedNotification {
auth_mode: auth.map(CodexAuth::api_auth_mode),
plan_type: auth.and_then(CodexAuth::account_plan_type),
workspace_role,
is_workspace_owner,
}
}
fn spawn_live_workspace_role_update_for_auth(
outgoing: Arc<OutgoingMessageSender>,
auth_manager: Arc<AuthManager>,
chatgpt_base_url: String,
auth: Option<CodexAuth>,
) {
let Some(auth) = auth.filter(CodexAuth::is_chatgpt_auth) else {
return;
};
let expected_identity = auth_identity(&auth);
tokio::spawn(async move {
let (workspace_role, is_workspace_owner) =
resolve_workspace_role_and_owner_for_auth(&chatgpt_base_url, Some(&auth)).await;
let Some(workspace_role) = workspace_role else {
return;
};
let current_auth = auth_manager.auth_cached();
if current_auth.as_ref().map(auth_identity) != Some(expected_identity) {
return;
}
let payload = account_updated_notification_for_auth(
current_auth.as_ref(),
Some(workspace_role),
is_workspace_owner,
);
outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload))
.await;
});
}
impl CodexMessageProcessor {
pub(crate) fn handle_config_mutation(&self) {
self.clear_plugin_related_caches();
@@ -592,18 +498,10 @@ impl CodexMessageProcessor {
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
let auth = self.auth_manager.auth_cached();
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
account_updated_notification_for_auth(auth.as_ref(), workspace_role, is_workspace_owner)
}
fn spawn_live_workspace_role_update(&self, auth: Option<CodexAuth>) {
spawn_live_workspace_role_update_for_auth(
self.outgoing.clone(),
self.auth_manager.clone(),
self.config.chatgpt_base_url.clone(),
auth,
);
AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
}
}
async fn load_thread(
@@ -887,10 +785,6 @@ impl CodexMessageProcessor {
self.thread_shell_command(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadAddCreditsNudgeEmail { request_id, params } => {
self.thread_add_credits_nudge_email(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::SkillsList { request_id, params } => {
self.skills_list(to_connection_request_id(request_id), params)
.await;
@@ -1210,7 +1104,6 @@ impl CodexMessageProcessor {
self.current_account_updated_notification(),
))
.await;
self.spawn_live_workspace_role_update(self.auth_manager.auth_cached());
}
Err(error) => {
self.outgoing.send_error(request_id, error).await;
@@ -1335,7 +1228,7 @@ impl CodexMessageProcessor {
replace_cloud_requirements_loader(
cloud_requirements.as_ref(),
auth_manager.clone(),
chatgpt_base_url.clone(),
chatgpt_base_url,
codex_home,
);
sync_default_client_residency_requirement(
@@ -1344,27 +1237,17 @@ impl CodexMessageProcessor {
)
.await;
// Notify clients with the actual current auth mode immediately; the
// live workspace role is fetched below without delaying this update.
// Notify clients with the actual current auth mode.
let auth = auth_manager.auth_cached();
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
let payload_v2 = account_updated_notification_for_auth(
auth.as_ref(),
workspace_role,
is_workspace_owner,
);
let payload_v2 = AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
};
outgoing_clone
.send_server_notification(ServerNotification::AccountUpdated(
payload_v2,
))
.await;
spawn_live_workspace_role_update_for_auth(
outgoing_clone.clone(),
auth_manager.clone(),
chatgpt_base_url.clone(),
auth,
);
}
// Clear the active login if it matches this attempt. It may have been replaced or cancelled.
@@ -1459,7 +1342,7 @@ impl CodexMessageProcessor {
replace_cloud_requirements_loader(
cloud_requirements.as_ref(),
auth_manager.clone(),
chatgpt_base_url.clone(),
chatgpt_base_url,
codex_home,
);
sync_default_client_residency_requirement(
@@ -1469,24 +1352,15 @@ impl CodexMessageProcessor {
.await;
let auth = auth_manager.auth_cached();
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
let payload_v2 = account_updated_notification_for_auth(
auth.as_ref(),
workspace_role,
is_workspace_owner,
);
let payload_v2 = AccountUpdatedNotification {
auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode),
plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type),
};
outgoing_clone
.send_server_notification(ServerNotification::AccountUpdated(
payload_v2,
))
.await;
spawn_live_workspace_role_update_for_auth(
outgoing_clone.clone(),
auth_manager.clone(),
chatgpt_base_url.clone(),
auth,
);
}
let mut guard = active_login.lock().await;
@@ -1635,7 +1509,6 @@ impl CodexMessageProcessor {
self.current_account_updated_notification(),
))
.await;
self.spawn_live_workspace_role_update(self.auth_manager.auth_cached());
}
async fn logout_common(&self) -> std::result::Result<Option<AuthMode>, JSONRPCErrorError> {
@@ -1673,8 +1546,6 @@ impl CodexMessageProcessor {
let payload_v2 = AccountUpdatedNotification {
auth_mode: current_auth_method,
plan_type: None,
workspace_role: None,
is_workspace_owner: None,
};
self.outgoing
.send_server_notification(ServerNotification::AccountUpdated(payload_v2))
@@ -1773,16 +1644,13 @@ impl CodexMessageProcessor {
if !requires_openai_auth {
let response = GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth,
};
self.outgoing.send_response(request_id, response).await;
return;
}
let auth = self.auth_manager.auth_cached();
let account = match auth.as_ref() {
let account = match self.auth_manager.auth_cached() {
Some(auth) => match auth.auth_mode() {
CoreAuthMode::ApiKey => Some(Account::ApiKey {}),
CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => {
@@ -1809,17 +1677,12 @@ impl CodexMessageProcessor {
},
None => None,
};
let (workspace_role, is_workspace_owner) =
cached_workspace_role_and_owner_for_auth(auth.as_ref());
let response = GetAccountResponse {
account,
workspace_role,
is_workspace_owner,
requires_openai_auth,
};
self.outgoing.send_response(request_id, response).await;
self.spawn_live_workspace_role_update(auth);
}
async fn get_account_rate_limits(&self, request_id: ConnectionRequestId) {
@@ -1837,11 +1700,6 @@ impl CodexMessageProcessor {
self.outgoing.send_response(request_id, response).await;
}
Err(error) => {
tracing::warn!(
?request_id,
error = %error.message,
"account/rateLimits/read request failed"
);
self.outgoing.send_error(request_id, error).await;
}
}
@@ -3551,40 +3409,6 @@ impl CodexMessageProcessor {
}
}
async fn thread_add_credits_nudge_email(
&self,
request_id: ConnectionRequestId,
params: ThreadAddCreditsNudgeEmailParams,
) {
let ThreadAddCreditsNudgeEmailParams { thread_id } = params;
let (_, thread) = match self.load_thread(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self
.submit_core_op(&request_id, thread.as_ref(), Op::SendAddCreditsNudgeEmail)
.await
{
Ok(_) => {
self.outgoing
.send_response(request_id, ThreadAddCreditsNudgeEmailResponse {})
.await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to request add-credits nudge email: {err}"),
)
.await;
}
}
}
async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) {
let ThreadListParams {
cursor,
+1 -7
View File
@@ -735,7 +735,6 @@ mod tests {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(PlanType::Plus),
},
});
@@ -755,7 +754,6 @@ mod tests {
},
"secondary": null,
"credits": null,
"spendControl": null,
"planType": "plus"
}
},
@@ -771,8 +769,6 @@ mod tests {
let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification {
auth_mode: Some(AuthMode::ApiKey),
plan_type: None,
workspace_role: None,
is_workspace_owner: None,
});
let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification);
@@ -781,9 +777,7 @@ mod tests {
"method": "account/updated",
"params": {
"authMode": "apikey",
"planType": null,
"workspaceRole": null,
"isWorkspaceOwner": null
"planType": null
},
}),
serde_json::to_value(jsonrpc_notification)
@@ -60,11 +60,6 @@ impl ChatGptAuthFixture {
self
}
pub fn is_org_owner(mut self, is_org_owner: bool) -> Self {
self.claims.is_org_owner = Some(is_org_owner);
self
}
pub fn email(mut self, email: impl Into<String>) -> Self {
self.claims.email = Some(email.into());
self
@@ -87,7 +82,6 @@ pub struct ChatGptIdTokenClaims {
pub plan_type: Option<String>,
pub chatgpt_user_id: Option<String>,
pub chatgpt_account_id: Option<String>,
pub is_org_owner: Option<bool>,
}
impl ChatGptIdTokenClaims {
@@ -114,11 +108,6 @@ impl ChatGptIdTokenClaims {
self.chatgpt_account_id = Some(chatgpt_account_id.into());
self
}
pub fn is_org_owner(mut self, is_org_owner: bool) -> Self {
self.is_org_owner = Some(is_org_owner);
self
}
}
pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result<String> {
@@ -137,9 +126,6 @@ pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result<String> {
if let Some(chatgpt_account_id) = &claims.chatgpt_account_id {
auth_payload.insert("chatgpt_account_id".to_string(), json!(chatgpt_account_id));
}
if let Some(is_org_owner) = claims.is_org_owner {
auth_payload.insert("is_org_owner".to_string(), json!(is_org_owner));
}
if !auth_payload.is_empty() {
payload.insert(
"https://api.openai.com/auth".to_string(),
@@ -58,7 +58,6 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadForkParams;
@@ -415,16 +414,6 @@ impl McpProcess {
self.send_request("thread/shellCommand", params).await
}
/// Send a `thread/addCreditsNudgeEmail` JSON-RPC request.
pub async fn send_thread_add_credits_nudge_email_request(
&mut self,
params: ThreadAddCreditsNudgeEmailParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/addCreditsNudgeEmail", params)
.await
}
/// Send a `thread/rollback` JSON-RPC request.
pub async fn send_thread_rollback_request(
&mut self,
+1 -255
View File
@@ -28,7 +28,6 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WorkspaceRole;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::login_with_api_key;
use codex_protocol::account::PlanType as AccountPlanType;
@@ -56,7 +55,6 @@ struct CreateConfigTomlParams {
forced_workspace_id: Option<String>,
requires_openai_auth: Option<bool>,
base_url: Option<String>,
chatgpt_base_url: Option<String>,
}
fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> {
@@ -64,9 +62,6 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std:
let base_url = params
.base_url
.unwrap_or_else(|| "http://127.0.0.1:0/v1".to_string());
let chatgpt_base_url = params
.chatgpt_base_url
.unwrap_or_else(|| "http://127.0.0.1:0/backend-api".to_string());
let forced_line = if let Some(method) = params.forced_method {
format!("forced_login_method = \"{method}\"\n")
} else {
@@ -87,7 +82,6 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std:
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
chatgpt_base_url = "{chatgpt_base_url}"
{forced_line}
{forced_workspace_line}
@@ -128,49 +122,6 @@ async fn mock_device_code_usercode_failure(server: &MockServer, status: u16) {
.await;
}
async fn mock_accounts_check_role(server: &MockServer, account_id: &str, role: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/accounts/check/v4-2023-04-27"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"accounts": {
account_id: {
"account": {
"account_user_role": role,
}
}
},
"account_ordering": [account_id],
})))
.mount(server)
.await;
}
async fn mock_slow_accounts_check_role(
server: &MockServer,
account_id: &str,
role: &str,
delay: Duration,
) {
Mock::given(method("GET"))
.and(path("/backend-api/accounts/check/v4-2023-04-27"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(delay)
.set_body_json(json!({
"accounts": {
account_id: {
"account": {
"account_user_role": role,
}
}
},
"account_ordering": [account_id],
})),
)
.mount(server)
.await;
}
async fn mock_device_code_token_success(server: &MockServer) {
Mock::given(method("POST"))
.and(path("/api/accounts/deviceauth/token"))
@@ -270,12 +221,10 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
CreateConfigTomlParams {
requires_openai_auth: Some(true),
base_url: Some(format!("{}/v1", mock_server.uri())),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
write_models_cache(codex_home.path())?;
mock_accounts_check_role(&mock_server, "org-embedded", "standard-user").await;
let access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
@@ -313,20 +262,6 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
};
assert_eq!(payload.auth_mode, Some(AuthMode::ChatgptAuthTokens));
assert_eq!(payload.plan_type, Some(AccountPlanType::Pro));
assert_eq!(payload.workspace_role, None);
assert_eq!(payload.is_workspace_owner, None);
let note = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/updated"),
)
.await??;
let parsed: ServerNotification = note.try_into()?;
let ServerNotification::AccountUpdated(payload) = parsed else {
bail!("unexpected notification: {parsed:?}");
};
assert_eq!(payload.workspace_role, Some(WorkspaceRole::StandardUser));
assert_eq!(payload.is_workspace_owner, Some(false));
let get_id = mcp
.send_get_account_request(GetAccountParams {
@@ -346,8 +281,6 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
email: "embedded@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
}
);
@@ -415,8 +348,6 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
email: "embedded@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
}
);
@@ -1574,8 +1505,6 @@ async fn get_account_with_api_key() -> Result<()> {
let expected = GetAccountResponse {
account: Some(Account::ApiKey {}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
};
assert_eq!(received, expected);
@@ -1610,8 +1539,6 @@ async fn get_account_when_auth_not_required() -> Result<()> {
let expected = GetAccountResponse {
account: None,
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: false,
};
assert_eq!(received, expected);
@@ -1632,8 +1559,7 @@ async fn get_account_with_chatgpt() -> Result<()> {
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.email("user@example.com")
.plan_type("pro")
.is_org_owner(/*is_org_owner*/ true),
.plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
@@ -1657,190 +1583,12 @@ async fn get_account_with_chatgpt() -> Result<()> {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: Some(true),
requires_openai_auth: true,
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_emits_workspace_role_from_accounts_check() -> Result<()> {
let codex_home = TempDir::new()?;
let mock_server = MockServer::start().await;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
mock_accounts_check_role(&mock_server, "org-embedded", "account-owner").await;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.account_id("org-embedded")
.email("user@example.com")
.plan_type("pro")
.chatgpt_account_id("org-embedded"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
let expected = GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
};
assert_eq!(received, expected);
let note = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/updated"),
)
.await??;
let parsed: ServerNotification = note.try_into()?;
let ServerNotification::AccountUpdated(payload) = parsed else {
bail!("unexpected notification: {parsed:?}");
};
assert_eq!(payload.workspace_role, Some(WorkspaceRole::AccountOwner));
assert_eq!(payload.is_workspace_owner, Some(true));
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_does_not_guess_workspace_role_from_other_accounts() -> Result<()>
{
let codex_home = TempDir::new()?;
let mock_server = MockServer::start().await;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
mock_accounts_check_role(&mock_server, "org-other", "account-owner").await;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.account_id("org-current")
.email("user@example.com")
.plan_type("pro")
.chatgpt_account_id("org-current"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let params = GetAccountParams {
refresh_token: false,
};
let request_id = mcp.send_get_account_request(params).await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
let expected = GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
};
assert_eq!(received, expected);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_does_not_wait_for_accounts_check() -> Result<()> {
let codex_home = TempDir::new()?;
let mock_server = MockServer::start().await;
create_config_toml(
codex_home.path(),
CreateConfigTomlParams {
requires_openai_auth: Some(true),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
mock_slow_accounts_check_role(
&mock_server,
"org-embedded",
"standard-user",
Duration::from_secs(2),
)
.await;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt")
.account_id("org-embedded")
.email("user@example.com")
.plan_type("pro")
.chatgpt_account_id("org-embedded")
.is_org_owner(/*is_org_owner*/ true),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let resp: JSONRPCResponse = timeout(
Duration::from_millis(500),
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let received: GetAccountResponse = to_response(resp)?;
assert_eq!(
received,
GetAccountResponse {
account: Some(Account::Chatgpt {
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Pro,
}),
workspace_role: None,
is_workspace_owner: Some(true),
requires_openai_auth: true,
}
);
Ok(())
}
#[tokio::test]
async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1877,8 +1625,6 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result
email: "user@example.com".to_string(),
plan_type: AccountPlanType::Unknown,
}),
workspace_role: None,
is_workspace_owner: None,
requires_openai_auth: true,
};
assert_eq!(received, expected);
@@ -32,7 +32,6 @@ mod request_user_input;
mod review;
mod safety_check_downgrade;
mod skills_list;
mod thread_add_credits_nudge_email;
mod thread_archive;
mod thread_fork;
mod thread_list;
@@ -133,10 +133,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
}
}
}
],
"spend_control": {
"reached": true
}
]
});
Mock::given(method("GET"))
@@ -175,7 +172,6 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
spend_control: Some(codex_app_server_protocol::SpendControlSnapshot { reached: true }),
plan_type: Some(AccountPlanType::Pro),
},
rate_limits_by_limit_id: Some(
@@ -196,9 +192,6 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
resets_at: Some(secondary_reset_timestamp),
}),
credits: None,
spend_control: Some(codex_app_server_protocol::SpendControlSnapshot {
reached: true,
}),
plan_type: Some(AccountPlanType::Pro),
},
),
@@ -214,7 +207,6 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(AccountPlanType::Pro),
},
),
@@ -1,96 +0,0 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::to_response;
use codex_app_server_protocol::AddCreditsNudgeEmailNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailResult;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_add_credits_nudge_email_submits_core_op_and_emits_completion() -> Result<()> {
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let server = create_mock_responses_server_sequence(vec![]).await;
create_config_toml(codex_home.as_path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.as_path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let nudge_id = mcp
.send_thread_add_credits_nudge_email_request(ThreadAddCreditsNudgeEmailParams {
thread_id: thread.id.clone(),
})
.await?;
let nudge_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(nudge_id)),
)
.await??;
let _: ThreadAddCreditsNudgeEmailResponse =
to_response::<ThreadAddCreditsNudgeEmailResponse>(nudge_resp)?;
let notification: AddCreditsNudgeEmailNotification = serde_json::from_value(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("account/addCreditsNudgeEmail/completed"),
)
.await??
.params
.expect("account/addCreditsNudgeEmail/completed params"),
)?;
assert_eq!(notification.thread_id, thread.id);
assert_eq!(
notification.result,
AddCreditsNudgeEmailResult::Failed {
message: "codex account authentication required to notify workspace owner".to_string(),
}
);
Ok(())
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
+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
-2
View File
@@ -92,7 +92,6 @@ pub fn parse_rate_limit_for_limit(
primary,
secondary,
credits,
spend_control: None,
plan_type: None,
})
}
@@ -156,7 +155,6 @@ pub fn parse_rate_limit_event(payload: &str) -> Option<RateLimitSnapshot> {
primary,
secondary,
credits,
spend_control: None,
plan_type: event.plan_type,
})
}
-1
View File
@@ -26,7 +26,6 @@ bm25 = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
codex-analytics = { workspace = true }
codex-account = { workspace = true }
codex-api = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-apply-patch = { workspace = true }
-20
View File
@@ -1,20 +0,0 @@
use crate::config::Config;
use codex_account::AddCreditsNudgeEmailStatus;
use codex_account::SendAddCreditsNudgeEmailError;
use codex_account::WorkspaceOwnership;
use codex_login::AuthManager;
use codex_login::CodexAuth;
pub async fn send_add_credits_nudge_email(
config: &Config,
auth_manager: &AuthManager,
) -> Result<AddCreditsNudgeEmailStatus, SendAddCreditsNudgeEmailError> {
codex_account::send_add_credits_nudge_email(config.chatgpt_base_url.clone(), auth_manager).await
}
pub async fn resolve_workspace_role_and_owner_for_auth(
chatgpt_base_url: &str,
auth: Option<&CodexAuth>,
) -> WorkspaceOwnership {
codex_account::resolve_workspace_role_and_owner_for_auth(chatgpt_base_url, auth).await
}
-49
View File
@@ -4796,10 +4796,6 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
handlers::set_thread_name(&sess, sub.id.clone(), name).await;
false
}
Op::SendAddCreditsNudgeEmail => {
handlers::send_add_credits_nudge_email(&sess, &config, sub.id.clone()).await;
false
}
Op::RunUserShellCommand { command } => {
handlers::run_user_shell_command(&sess, sub.id.clone(), command).await;
false
@@ -4896,8 +4892,6 @@ mod handlers {
use crate::tasks::execute_user_shell_command;
use codex_mcp::collect_mcp_snapshot_from_manager;
use codex_mcp::compute_auth_statuses;
use codex_protocol::protocol::AddCreditsNudgeEmailResponseEvent;
use codex_protocol::protocol::AddCreditsNudgeEmailStatus;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
@@ -4942,48 +4936,6 @@ mod handlers {
sess.close_unified_exec_processes().await;
}
pub async fn send_add_credits_nudge_email(
sess: &Arc<Session>,
config: &Config,
sub_id: String,
) {
match crate::send_add_credits_nudge_email(config, &sess.services.auth_manager).await {
Ok(status) => {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::AddCreditsNudgeEmailResponse(
AddCreditsNudgeEmailResponseEvent {
result: Ok(add_credits_nudge_email_status_to_event(status)),
},
),
})
.await;
}
Err(err) => {
sess.send_event_raw(Event {
id: sub_id,
msg: EventMsg::AddCreditsNudgeEmailResponse(
AddCreditsNudgeEmailResponseEvent {
result: Err(err.to_string()),
},
),
})
.await;
}
}
}
fn add_credits_nudge_email_status_to_event(
status: crate::AddCreditsNudgeEmailStatus,
) -> AddCreditsNudgeEmailStatus {
match status {
crate::AddCreditsNudgeEmailStatus::Sent => AddCreditsNudgeEmailStatus::Sent,
crate::AddCreditsNudgeEmailStatus::CooldownActive => {
AddCreditsNudgeEmailStatus::CooldownActive
}
}
}
pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) {
sess.send_event_raw(Event {
id: sub_id,
@@ -7290,7 +7242,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::AddCreditsNudgeEmailResponse(_)
| EventMsg::RealtimeConversationListVoicesResponse(_)
| EventMsg::SkillsUpdateAvailable
| EventMsg::PlanUpdate(_)
-6
View File
@@ -2012,7 +2012,6 @@ async fn set_rate_limits_retains_previous_credits() {
unlimited: false,
balance: Some("10.00".to_string()),
}),
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
};
state.set_rate_limits(initial.clone());
@@ -2031,7 +2030,6 @@ async fn set_rate_limits_retains_previous_credits() {
resets_at: Some(1_900),
}),
credits: None,
spend_control: None,
plan_type: None,
};
state.set_rate_limits(update.clone());
@@ -2044,7 +2042,6 @@ async fn set_rate_limits_retains_previous_credits() {
primary: update.primary.clone(),
secondary: update.secondary,
credits: initial.credits,
spend_control: None,
plan_type: initial.plan_type,
})
);
@@ -2121,7 +2118,6 @@ async fn set_rate_limits_updates_plan_type_when_present() {
unlimited: false,
balance: Some("15.00".to_string()),
}),
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
};
state.set_rate_limits(initial.clone());
@@ -2136,7 +2132,6 @@ async fn set_rate_limits_updates_plan_type_when_present() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Pro),
};
state.set_rate_limits(update.clone());
@@ -2149,7 +2144,6 @@ async fn set_rate_limits_updates_plan_type_when_present() {
primary: update.primary,
secondary: update.secondary,
credits: initial.credits,
spend_control: None,
plan_type: update.plan_type,
})
);
-7
View File
@@ -5,7 +5,6 @@
// the TUI or the tracing stack).
#![deny(clippy::print_stdout, clippy::print_stderr)]
mod account;
mod apply_patch;
mod apps;
mod arc_monitor;
@@ -113,13 +112,7 @@ mod stream_events_utils;
pub mod test_support;
mod unified_exec;
pub mod windows_sandbox;
pub use account::resolve_workspace_role_and_owner_for_auth;
pub use account::send_add_credits_nudge_email;
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
pub use codex_account::AddCreditsNudgeEmailStatus;
pub use codex_account::SendAddCreditsNudgeEmailError;
pub use codex_account::WorkspaceOwnership;
pub use codex_account::WorkspaceRole;
pub use codex_protocol::config_types::ModelProviderAuthInfo;
mod event_mapping;
pub mod review_format;
-3
View File
@@ -229,9 +229,6 @@ fn merge_rate_limit_fields(
if snapshot.credits.is_none() {
snapshot.credits = previous.and_then(|prior| prior.credits.clone());
}
if snapshot.spend_control.is_none() {
snapshot.spend_control = previous.and_then(|prior| prior.spend_control.clone());
}
if snapshot.plan_type.is_none() {
snapshot.plan_type = previous.and_then(|prior| prior.plan_type);
}
-6
View File
@@ -48,7 +48,6 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
});
@@ -76,7 +75,6 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
});
state.set_rate_limits(RateLimitSnapshot {
@@ -89,7 +87,6 @@ async fn set_rate_limits_defaults_to_codex_when_limit_id_missing_after_other_buc
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
});
@@ -121,7 +118,6 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
unlimited: false,
balance: Some("50".to_string()),
}),
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
});
@@ -135,7 +131,6 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
});
@@ -155,7 +150,6 @@ async fn set_rate_limits_carries_credits_and_plan_type_from_codex_to_codex_other
unlimited: false,
balance: Some("50".to_string()),
}),
spend_control: None,
plan_type: Some(codex_protocol::account::PlanType::Plus),
})
);
-3
View File
@@ -2375,7 +2375,6 @@ async fn token_count_includes_rate_limits_snapshot() {
"resets_at": 1704074400
},
"credits": null,
"spend_control": null,
"plan_type": null
}
})
@@ -2427,7 +2426,6 @@ async fn token_count_includes_rate_limits_snapshot() {
"resets_at": 1704074400
},
"credits": null,
"spend_control": null,
"plan_type": null
}
})
@@ -2502,7 +2500,6 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> {
"resets_at": null
},
"credits": null,
"spend_control": null,
"plan_type": null
});
@@ -1033,7 +1033,6 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() {
"resets_at": null
},
"credits": null,
"spend_control": null,
"plan_type": null
}
})
-1
View File
@@ -128,7 +128,6 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() {
chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)),
chatgpt_user_id: Some("user-12345".to_string()),
chatgpt_account_id: None,
is_org_owner: None,
raw_jwt: fake_jwt,
},
access_token: "test-access-token".to_string(),
-8
View File
@@ -302,14 +302,6 @@ impl CodexAuth {
.and_then(|t| t.id_token.chatgpt_user_id)
}
/// Returns `Some(true)` when the current ChatGPT token marks the user as a
/// workspace owner, `Some(false)` for non-owners, and `None` when auth is
/// unavailable or the token omits the claim.
pub fn is_workspace_owner(&self) -> Option<bool> {
self.get_current_token_data()
.and_then(|t| t.id_token.is_org_owner)
}
/// Account-facing plan classification derived from the current token.
/// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
/// mapped from the ID token's internal plan value. Prefer this when you
-6
View File
@@ -36,8 +36,6 @@ pub struct IdTokenInfo {
pub chatgpt_user_id: Option<String>,
/// Organization/workspace identifier associated with the token, if present.
pub chatgpt_account_id: Option<String>,
/// Whether the current user owns the workspace, when present in the token.
pub is_org_owner: Option<bool>,
pub raw_jwt: String,
}
@@ -90,8 +88,6 @@ struct AuthClaims {
user_id: Option<String>,
#[serde(default)]
chatgpt_account_id: Option<String>,
#[serde(default)]
is_org_owner: Option<bool>,
}
#[derive(Deserialize)]
@@ -143,7 +139,6 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<IdTokenInfo, IdTokenInfoErr
chatgpt_plan_type: auth.chatgpt_plan_type,
chatgpt_user_id: auth.chatgpt_user_id.or(auth.user_id),
chatgpt_account_id: auth.chatgpt_account_id,
is_org_owner: auth.is_org_owner,
}),
None => Ok(IdTokenInfo {
email,
@@ -151,7 +146,6 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result<IdTokenInfo, IdTokenInfoErr
chatgpt_plan_type: None,
chatgpt_user_id: None,
chatgpt_account_id: None,
is_org_owner: None,
}),
}
}
-15
View File
@@ -107,21 +107,6 @@ fn id_token_info_parses_usage_based_business_plans() {
assert_eq!(enterprise_cbp.is_workspace_account(), true);
}
#[test]
fn id_token_info_parses_workspace_owner_flag() {
let fake_jwt = fake_jwt(serde_json::json!({
"email": "owner@example.com",
"https://api.openai.com/auth": {
"chatgpt_plan_type": "self_serve_business_usage_based",
"is_org_owner": true
}
}));
let info = parse_chatgpt_jwt_claims(&fake_jwt).expect("should parse");
assert_eq!(info.email.as_deref(), Some("owner@example.com"));
assert_eq!(info.is_org_owner, Some(true));
}
#[test]
fn id_token_info_handles_missing_fields() {
let fake_jwt = fake_jwt(serde_json::json!({ "sub": "123" }));
@@ -339,7 +339,6 @@ async fn run_codex_tool_session_inner(
| EventMsg::McpToolCallEnd(_)
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::AddCreditsNudgeEmailResponse(_)
| EventMsg::RealtimeConversationListVoicesResponse(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::TerminalInteraction(_)
-1
View File
@@ -35,7 +35,6 @@ fn rate_limit_snapshot() -> RateLimitSnapshot {
resets_at: Some(secondary_reset_at),
}),
credits: None,
spend_control: None,
plan_type: None,
}
}
-25
View File
@@ -648,9 +648,6 @@ pub enum Op {
/// Request a code review from the agent.
Review { review_request: ReviewRequest },
/// Ask the workspace owner to add Codex credits to the current ChatGPT workspace.
SendAddCreditsNudgeEmail,
/// Request to shut down codex instance.
Shutdown,
@@ -761,7 +758,6 @@ impl Op {
Self::Undo => "undo",
Self::ThreadRollback { .. } => "thread_rollback",
Self::Review { .. } => "review",
Self::SendAddCreditsNudgeEmail => "send_add_credits_nudge_email",
Self::Shutdown => "shutdown",
Self::RunUserShellCommand { .. } => "run_user_shell_command",
Self::ListModels => "list_models",
@@ -1520,9 +1516,6 @@ pub enum EventMsg {
/// List of skills available to the agent.
ListSkillsResponse(ListSkillsResponseEvent),
/// Response to SendAddCreditsNudgeEmail.
AddCreditsNudgeEmailResponse(AddCreditsNudgeEmailResponseEvent),
/// List of voices supported by realtime conversation streams.
RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent),
@@ -1576,18 +1569,6 @@ pub enum EventMsg {
CollabResumeEnd(CollabResumeEndEvent),
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum AddCreditsNudgeEmailStatus {
Sent,
CooldownActive,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct AddCreditsNudgeEmailResponseEvent {
pub result: Result<AddCreditsNudgeEmailStatus, String>,
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
@@ -2154,7 +2135,6 @@ pub struct RateLimitSnapshot {
pub primary: Option<RateLimitWindow>,
pub secondary: Option<RateLimitWindow>,
pub credits: Option<CreditsSnapshot>,
pub spend_control: Option<SpendControlSnapshot>,
pub plan_type: Option<crate::account::PlanType>,
}
@@ -2177,11 +2157,6 @@ pub struct CreditsSnapshot {
pub balance: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)]
pub struct SpendControlSnapshot {
pub reached: bool,
}
// Includes prompts, tools and space to call compact.
const BASELINE_TOKENS: i64 = 12000;
-1
View File
@@ -164,7 +164,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
| EventMsg::McpStartupUpdate(_)
| EventMsg::McpStartupComplete(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::AddCreditsNudgeEmailResponse(_)
| EventMsg::PlanUpdate(_)
| EventMsg::ShutdownComplete
| EventMsg::DeprecationNotice(_)
-63
View File
@@ -538,9 +538,6 @@ impl ThreadEventStore {
ThreadBufferedEvent::Request(_)
| ThreadBufferedEvent::Notification(ServerNotification::HookStarted(_))
| ThreadBufferedEvent::Notification(ServerNotification::HookCompleted(_))
| ThreadBufferedEvent::Notification(
ServerNotification::AddCreditsNudgeEmailCompleted(_),
)
| ThreadBufferedEvent::FeedbackSubmission(_)
)
}
@@ -1100,8 +1097,6 @@ impl App {
feedback: self.feedback.clone(),
is_first_run: false,
status_account_display: self.chat_widget.status_account_display().cloned(),
initial_workspace_role: self.chat_widget.current_workspace_role(),
initial_is_workspace_owner: self.chat_widget.current_is_workspace_owner(),
initial_plan_type: self.chat_widget.current_plan_type(),
model: Some(self.chat_widget.current_model().to_string()),
startup_tooltip_override: None,
@@ -2432,10 +2427,6 @@ impl App {
.await?;
Ok(true)
}
AppCommandView::SendAddCreditsNudgeEmail => {
app_server.thread_add_credits_nudge_email(thread_id).await?;
Ok(true)
}
AppCommandView::ReloadUserConfig => {
app_server.reload_user_config().await?;
Ok(true)
@@ -3663,8 +3654,6 @@ impl App {
let has_chatgpt_account = bootstrap.has_chatgpt_account;
let requires_openai_auth = bootstrap.requires_openai_auth;
let status_account_display = bootstrap.status_account_display.clone();
let initial_workspace_role = bootstrap.workspace_role;
let initial_is_workspace_owner = bootstrap.is_workspace_owner;
let initial_plan_type = bootstrap.plan_type;
let session_telemetry = SessionTelemetry::new(
ThreadId::new(),
@@ -3714,8 +3703,6 @@ impl App {
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
initial_workspace_role,
initial_is_workspace_owner,
initial_plan_type,
model: Some(model.clone()),
startup_tooltip_override,
@@ -3750,8 +3737,6 @@ impl App {
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
initial_workspace_role,
initial_is_workspace_owner,
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
@@ -3791,8 +3776,6 @@ impl App {
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
initial_workspace_role,
initial_is_workspace_owner,
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
@@ -4577,23 +4560,6 @@ impl App {
}
}
},
AppEvent::NotifyWorkspaceOwner => {
if self.active_thread_id.is_some() {
self.chat_widget.start_notify_workspace_owner();
if let Err(err) = self
.submit_active_thread_op(app_server, Op::SendAddCreditsNudgeEmail.into())
.await
{
self.chat_widget
.finish_notify_workspace_owner(Err(err.to_string()));
return Err(err);
}
} else {
self.chat_widget.finish_notify_workspace_owner(Err(
"no active thread is available".to_string(),
));
}
}
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
}
@@ -6374,8 +6340,6 @@ mod tests {
use crate::multi_agents::AgentPickerThreadEntry;
use assert_matches::assert_matches;
use codex_app_server_protocol::AddCreditsNudgeEmailNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailResult;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::AdditionalPermissionProfile;
@@ -6763,8 +6727,6 @@ mod tests {
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: false,
status_account_display: None,
initial_workspace_role: None,
initial_is_workspace_owner: None,
initial_plan_type: None,
model: Some(model),
startup_tooltip_override: None,
@@ -9668,29 +9630,6 @@ guardian_approval = true
);
}
#[test]
fn thread_event_store_rebase_preserves_add_credits_nudge_email_completion() {
let thread_id = ThreadId::new();
let mut store = ThreadEventStore::new(/*capacity*/ 8);
let notification =
ServerNotification::AddCreditsNudgeEmailCompleted(AddCreditsNudgeEmailNotification {
thread_id: thread_id.to_string(),
result: AddCreditsNudgeEmailResult::Sent,
});
store.push_notification(notification.clone());
store.rebase_buffer_after_session_refresh();
let snapshot = store.snapshot();
let [ThreadBufferedEvent::Notification(actual)] = snapshot.events.as_slice() else {
panic!("expected add-credits nudge email completion notification");
};
assert_eq!(
serde_json::to_value(actual).expect("notification should serialize"),
serde_json::to_value(notification).expect("notification should serialize"),
);
}
#[test]
fn build_feedback_upload_params_includes_thread_id_and_rollout_path() {
let thread_id = ThreadId::new();
@@ -10752,8 +10691,6 @@ guardian_approval = true
feedback: app.feedback.clone(),
is_first_run: false,
status_account_display: app.chat_widget.status_account_display().cloned(),
initial_workspace_role: app.chat_widget.current_workspace_role(),
initial_is_workspace_owner: app.chat_widget.current_is_workspace_owner(),
initial_plan_type: app.chat_widget.current_plan_type(),
model: Some(app.chat_widget.current_model().to_string()),
startup_tooltip_override: None,
@@ -176,8 +176,6 @@ impl App {
notification.auth_mode,
notification.plan_type,
),
notification.workspace_role,
notification.is_workspace_owner,
notification.plan_type,
matches!(
notification.auth_mode,
@@ -402,9 +400,6 @@ fn server_notification_thread_target(
ServerNotification::ThreadRealtimeClosed(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::AddCreditsNudgeEmailCompleted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::SkillsChanged(_)
| ServerNotification::McpServerStatusUpdated(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
@@ -1032,14 +1027,10 @@ fn app_server_codex_error_info_to_core(
#[cfg(test)]
mod tests {
use super::ServerNotificationThreadTarget;
use super::command_execution_started_event;
use super::server_notification_thread_events;
use super::server_notification_thread_target;
use super::thread_snapshot_events;
use super::turn_snapshot_events;
use codex_app_server_protocol::AddCreditsNudgeEmailNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailResult;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::CommandAction;
@@ -1070,19 +1061,6 @@ mod tests {
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn add_credits_nudge_email_completion_targets_thread() {
let thread_id = ThreadId::new();
let target = server_notification_thread_target(
&ServerNotification::AddCreditsNudgeEmailCompleted(AddCreditsNudgeEmailNotification {
thread_id: thread_id.to_string(),
result: AddCreditsNudgeEmailResult::Sent,
}),
);
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
#[test]
fn bridges_completed_agent_messages_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
-2
View File
@@ -98,7 +98,6 @@ pub(crate) enum AppCommandView<'a> {
SetThreadName {
name: &'a str,
},
SendAddCreditsNudgeEmail,
Shutdown,
ThreadRollback {
num_turns: u32,
@@ -392,7 +391,6 @@ impl AppCommand {
num_turns: *num_turns,
},
Op::Review { review_request } => AppCommandView::Review { review_request },
Op::SendAddCreditsNudgeEmail => AppCommandView::SendAddCreditsNudgeEmail,
op => AppCommandView::Other(op),
}
}
-4
View File
@@ -170,10 +170,6 @@ pub(crate) enum AppEvent {
result: Result<Vec<RateLimitSnapshot>, String>,
},
/// Ask Codex to notify the current workspace owner that credits need to be
/// added.
NotifyWorkspaceOwner,
/// Result of prefetching connectors.
ConnectorsLoaded {
result: Result<ConnectorsSnapshot, String>,
+1 -51
View File
@@ -24,8 +24,6 @@ use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadCompactStartParams;
@@ -105,8 +103,6 @@ pub(crate) struct AppServerBootstrap {
pub(crate) account_email: Option<String>,
pub(crate) auth_mode: Option<TelemetryAuthMode>,
pub(crate) status_account_display: Option<StatusAccountDisplay>,
pub(crate) workspace_role: Option<codex_app_server_protocol::WorkspaceRole>,
pub(crate) is_workspace_owner: Option<bool>,
pub(crate) plan_type: Option<codex_protocol::account::PlanType>,
/// Whether the configured model provider needs OpenAI-style auth. Combined
/// with `has_chatgpt_account` to decide if a startup rate-limit prefetch
@@ -221,8 +217,6 @@ impl AppServerSession {
account_email,
auth_mode,
status_account_display,
workspace_role,
is_workspace_owner,
plan_type,
feedback_audience,
has_chatgpt_account,
@@ -232,8 +226,6 @@ impl AppServerSession {
Some(TelemetryAuthMode::ApiKey),
Some(StatusAccountDisplay::ApiKey),
None,
None,
None,
FeedbackAudience::External,
false,
),
@@ -250,30 +242,17 @@ impl AppServerSession {
email: Some(email),
plan: Some(plan_type_display_name(plan_type)),
}),
account.workspace_role,
account.is_workspace_owner,
Some(plan_type),
feedback_audience,
true,
)
}
None => (
None,
None,
None,
None,
None,
None,
FeedbackAudience::External,
false,
),
None => (None, None, None, None, FeedbackAudience::External, false),
};
Ok(AppServerBootstrap {
account_email,
auth_mode,
status_account_display,
workspace_role,
is_workspace_owner,
plan_type,
requires_openai_auth: account.requires_openai_auth,
default_model,
@@ -581,24 +560,6 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_add_credits_nudge_email(
&mut self,
thread_id: ThreadId,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadAddCreditsNudgeEmailResponse = self
.client
.request_typed(ClientRequest::ThreadAddCreditsNudgeEmail {
request_id,
params: ThreadAddCreditsNudgeEmailParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("thread/addCreditsNudgeEmail failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_background_terminals_clean(
&mut self,
thread_id: ThreadId,
@@ -1173,9 +1134,6 @@ pub(crate) fn app_server_rate_limit_snapshot_to_core(
primary: snapshot.primary.map(app_server_rate_limit_window_to_core),
secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core),
credits: snapshot.credits.map(app_server_credits_snapshot_to_core),
spend_control: snapshot
.spend_control
.map(app_server_spend_control_snapshot_to_core),
plan_type: snapshot.plan_type,
}
}
@@ -1200,14 +1158,6 @@ fn app_server_credits_snapshot_to_core(
}
}
fn app_server_spend_control_snapshot_to_core(
snapshot: codex_app_server_protocol::SpendControlSnapshot,
) -> codex_protocol::protocol::SpendControlSnapshot {
codex_protocol::protocol::SpendControlSnapshot {
reached: snapshot.reached,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -648,23 +648,7 @@ impl BottomPaneView for ListSelectionView {
&& !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
if let Some((visible_idx, _)) = self
.filtered_indices
.iter()
.enumerate()
.find(|(_, actual_idx)| {
self.items.get(**actual_idx).is_some_and(|item| {
item.display_shortcut
.as_ref()
.is_some_and(|shortcut| shortcut.is_press(key_event))
&& item.disabled_reason.is_none()
&& !item.is_disabled
})
})
{
self.state.selected_idx = Some(visible_idx);
self.accept();
} else if let Some(idx) = c
if let Some(idx) = c
.to_digit(10)
.map(|d| d as usize)
.and_then(|d| d.checked_sub(1))
+6 -339
View File
@@ -67,7 +67,6 @@ use crate::terminal_title::clear_terminal_title;
use crate::terminal_title::set_terminal_title;
use crate::text_formatting::proper_join;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AddCreditsNudgeEmailResult as AppServerAddCreditsNudgeEmailResult;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState;
@@ -92,7 +91,6 @@ use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnPlanStepStatus;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WorkspaceRole as AppServerWorkspaceRole;
use codex_chatgpt::connectors;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::Notifications;
@@ -135,7 +133,6 @@ use codex_protocol::models::local_image_label_text;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg;
use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus;
use codex_protocol::protocol::AddCreditsNudgeEmailStatus;
#[cfg(test)]
use codex_protocol::protocol::AgentMessageDeltaEvent;
#[cfg(test)]
@@ -195,7 +192,6 @@ use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_protocol::protocol::SpendControlSnapshot;
#[cfg(test)]
use codex_protocol::protocol::StreamErrorEvent;
use codex_protocol::protocol::TerminalInteractionEvent;
@@ -561,8 +557,6 @@ pub(crate) struct ChatWidgetInit {
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) is_first_run: bool,
pub(crate) status_account_display: Option<StatusAccountDisplay>,
pub(crate) initial_workspace_role: Option<AppServerWorkspaceRole>,
pub(crate) initial_is_workspace_owner: Option<bool>,
pub(crate) initial_plan_type: Option<PlanType>,
pub(crate) model: Option<String>,
pub(crate) startup_tooltip_override: Option<String>,
@@ -609,32 +603,6 @@ enum RateLimitErrorKind {
Generic,
}
pub(crate) const CODEX_USAGE_SETTINGS_URL: &str = "https://chatgpt.com/codex/settings/usage";
const WORKSPACE_OWNER_NOTIFICATION_PROMPT: &str =
"Your workspace is out of credits. Request more from your workspace owner? [y/N]";
const WORKSPACE_OWNER_NOTIFICATION_TITLE: &str = "Request more credits from your workspace owner?";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UsageBasedWorkspaceRateLimitState {
OwnerCreditsDepleted,
OwnerSpendCapReached,
MemberCreditsDepleted,
MemberSpendCapReached,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UsageBasedWorkspaceBlockKind {
CreditsDepleted,
SpendCapReached,
}
fn is_usage_based_workspace_plan(plan_type: PlanType) -> bool {
matches!(
plan_type,
PlanType::SelfServeBusinessUsageBased | PlanType::EnterpriseCbpUsageBased
)
}
#[cfg(test)]
fn core_rate_limit_error_kind(info: &CoreCodexErrorInfo) -> Option<RateLimitErrorKind> {
match info {
@@ -790,8 +758,6 @@ pub(crate) struct ChatWidget {
/// The currently active collaboration mask, if any.
active_collaboration_mask: Option<CollaborationModeMask>,
has_chatgpt_account: bool,
workspace_role: Option<AppServerWorkspaceRole>,
is_workspace_owner: Option<bool>,
model_catalog: Arc<ModelCatalog>,
session_telemetry: SessionTelemetry,
session_header: SessionHeader,
@@ -802,8 +768,6 @@ pub(crate) struct ChatWidget {
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
plan_type: Option<PlanType>,
notify_workspace_owner_in_flight: bool,
pending_workspace_owner_notification_prompt: bool,
rate_limit_warnings: RateLimitWarningState,
rate_limit_switch_prompt: RateLimitSwitchPromptState,
adaptive_chunking: AdaptiveChunkingPolicy,
@@ -2721,15 +2685,6 @@ impl ChatWidget {
balance: credits.balance.clone(),
});
}
if snapshot.spend_control.is_none() {
snapshot.spend_control = self
.rate_limit_snapshots_by_limit_id
.get(&limit_id)
.and_then(|display| display.spend_control.as_ref())
.map(|spend_control| SpendControlSnapshot {
reached: spend_control.reached,
});
}
self.plan_type = snapshot.plan_type.or(self.plan_type);
@@ -2798,7 +2753,6 @@ impl ChatWidget {
} else {
self.rate_limit_snapshots_by_limit_id.clear();
}
self.maybe_show_pending_workspace_owner_notification_prompt();
self.refresh_status_line();
}
/// Finalize any active exec as failed and stop/clear agent-turn UI state.
@@ -2841,34 +2795,15 @@ impl ChatWidget {
}
fn on_error(&mut self, message: String) {
self.on_error_with_hint(message, /*hint*/ None);
}
fn on_error_with_hint(&mut self, message: String, hint: Option<String>) {
self.submit_pending_steers_after_interrupt = false;
self.finalize_turn();
self.add_to_history(history_cell::new_error_event_with_hint(message, hint));
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
// After an error ends the turn, try sending the next queued input.
self.maybe_send_next_queued_input();
}
fn on_rate_limit_error(&mut self, message: String) {
if self.should_prefetch_rate_limits() {
self.request_rate_limit_refresh();
}
let guidance = self.usage_based_workspace_rate_limit_error_guidance();
let should_prompt_workspace_owner = self.should_prompt_workspace_owner_notification();
let hint = guidance.as_ref().and_then(|(_, hint)| hint.clone());
let message = guidance.map(|(message, _)| message).unwrap_or(message);
self.on_error_with_hint(message, hint);
if should_prompt_workspace_owner {
self.pending_workspace_owner_notification_prompt = true;
self.maybe_show_pending_workspace_owner_notification_prompt();
}
}
fn handle_non_retry_error(
&mut self,
message: String,
@@ -2885,7 +2820,7 @@ impl ChatWidget {
match info {
RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message),
RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => {
self.on_rate_limit_error(message);
self.on_error(message)
}
}
} else {
@@ -4778,8 +4713,6 @@ impl ChatWidget {
feedback,
is_first_run,
status_account_display,
initial_workspace_role,
initial_is_workspace_owner,
initial_plan_type,
model,
startup_tooltip_override,
@@ -4841,8 +4774,6 @@ impl ChatWidget {
current_collaboration_mode,
active_collaboration_mask,
has_chatgpt_account,
workspace_role: initial_workspace_role,
is_workspace_owner: initial_is_workspace_owner,
model_catalog,
session_telemetry,
session_header: SessionHeader::new(header_model),
@@ -4853,8 +4784,6 @@ impl ChatWidget {
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
plan_type: initial_plan_type,
notify_workspace_owner_in_flight: false,
pending_workspace_owner_notification_prompt: false,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
adaptive_chunking: AdaptiveChunkingPolicy::default(),
@@ -5495,7 +5424,9 @@ impl ChatWidget {
}
SlashCommand::Status => {
if self.should_prefetch_rate_limits() {
let request_id = self.next_rate_limit_refresh_request_id();
let request_id = self.next_status_refresh_request_id;
self.next_status_refresh_request_id =
self.next_status_refresh_request_id.wrapping_add(1);
self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
self.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
@@ -6756,18 +6687,6 @@ impl ChatWidget {
);
}
}
ServerNotification::AddCreditsNudgeEmailCompleted(notification) => {
let result = match notification.result {
AppServerAddCreditsNudgeEmailResult::Sent => {
Ok(AddCreditsNudgeEmailStatus::Sent)
}
AppServerAddCreditsNudgeEmailResult::CooldownActive => {
Ok(AddCreditsNudgeEmailStatus::CooldownActive)
}
AppServerAddCreditsNudgeEmailResult::Failed { message } => Err(message),
};
self.finish_notify_workspace_owner(result);
}
ServerNotification::ThreadRealtimeSdp(notification) => {
if !from_replay {
self.on_realtime_conversation_sdp(notification.sdp);
@@ -7183,7 +7102,7 @@ impl ChatWidget {
self.on_server_overloaded_error(message)
}
RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => {
self.on_rate_limit_error(message)
self.on_error(message)
}
}
} else {
@@ -7239,9 +7158,6 @@ impl ChatWidget {
EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev),
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
EventMsg::AddCreditsNudgeEmailResponse(ev) => {
self.finish_notify_workspace_owner(ev.result);
}
EventMsg::SkillsUpdateAvailable => {
self.submit_op(AppCommand::list_skills(
Vec::new(),
@@ -9781,268 +9697,19 @@ impl ChatWidget {
self.plan_type
}
pub(crate) fn current_is_workspace_owner(&self) -> Option<bool> {
self.workspace_role
.map(|role| {
matches!(
role,
AppServerWorkspaceRole::AccountOwner | AppServerWorkspaceRole::AccountAdmin
)
})
.or(self.is_workspace_owner)
}
pub(crate) fn current_workspace_role(&self) -> Option<AppServerWorkspaceRole> {
self.workspace_role
}
pub(crate) fn has_chatgpt_account(&self) -> bool {
self.has_chatgpt_account
}
fn usage_based_workspace_rate_limit_state(&self) -> Option<UsageBasedWorkspaceRateLimitState> {
let block_kind = self.usage_based_workspace_block_kind()?;
let is_workspace_owner = self.current_is_workspace_owner()?;
Some(match (is_workspace_owner, block_kind) {
(true, UsageBasedWorkspaceBlockKind::CreditsDepleted) => {
UsageBasedWorkspaceRateLimitState::OwnerCreditsDepleted
}
(true, UsageBasedWorkspaceBlockKind::SpendCapReached) => {
UsageBasedWorkspaceRateLimitState::OwnerSpendCapReached
}
(false, UsageBasedWorkspaceBlockKind::CreditsDepleted) => {
UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted
}
(false, UsageBasedWorkspaceBlockKind::SpendCapReached) => {
UsageBasedWorkspaceRateLimitState::MemberSpendCapReached
}
})
}
fn usage_based_workspace_block_kind(&self) -> Option<UsageBasedWorkspaceBlockKind> {
let plan_type = self.plan_type?;
if !is_usage_based_workspace_plan(plan_type) {
return None;
}
let codex_snapshot = self.rate_limit_snapshots_by_limit_id.get("codex")?;
let credits_depleted = codex_snapshot
.credits
.as_ref()
.is_some_and(|credits| !credits.has_credits);
let spend_control_reached = codex_snapshot
.spend_control
.as_ref()
.map(|spend_control| spend_control.reached);
let blocked_state = match (credits_depleted, spend_control_reached) {
(true, _) => Some(true),
(false, Some(true)) => Some(false),
(false, Some(false)) => None,
// Older snapshots do not carry spend-control state, so preserve the
// historical interpretation for compatibility.
(false, None) => Some(false),
}?;
Some(match blocked_state {
true => UsageBasedWorkspaceBlockKind::CreditsDepleted,
false => UsageBasedWorkspaceBlockKind::SpendCapReached,
})
}
fn usage_based_workspace_rate_limit_error_guidance(&self) -> Option<(String, Option<String>)> {
if let Some(state) = self.usage_based_workspace_rate_limit_state() {
return match state {
UsageBasedWorkspaceRateLimitState::OwnerCreditsDepleted => Some((
"Your workspace is out of credits.".to_string(),
Some(format!(
"Visit {CODEX_USAGE_SETTINGS_URL} to add workspace credits and continue using Codex."
)),
)),
UsageBasedWorkspaceRateLimitState::OwnerSpendCapReached => Some((
"Your workspace has reached its spend cap.".to_string(),
Some(format!(
"Visit {CODEX_USAGE_SETTINGS_URL} to increase your workspace spend cap and continue using Codex."
)),
)),
UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted => {
Some((WORKSPACE_OWNER_NOTIFICATION_PROMPT.to_string(), None))
}
UsageBasedWorkspaceRateLimitState::MemberSpendCapReached => Some((
"Your workspace has reached its spend cap.".to_string(),
Some(format!(
"Ask an admin to increase your workspace spend cap. Visit {CODEX_USAGE_SETTINGS_URL} for usage settings."
)),
)),
};
}
match self.usage_based_workspace_block_kind()? {
UsageBasedWorkspaceBlockKind::CreditsDepleted => Some((
"Your workspace is out of credits.".to_string(),
Some(format!(
"If you're the workspace owner, visit {CODEX_USAGE_SETTINGS_URL} to add credits. Otherwise wait a moment while Codex refreshes your workspace status."
)),
)),
UsageBasedWorkspaceBlockKind::SpendCapReached => Some((
"Your workspace has reached its spend cap.".to_string(),
Some(format!(
"Ask an admin to increase the workspace spend cap, or visit {CODEX_USAGE_SETTINGS_URL} for usage settings."
)),
)),
}
}
fn next_rate_limit_refresh_request_id(&mut self) -> u64 {
let request_id = self.next_status_refresh_request_id;
self.next_status_refresh_request_id = self.next_status_refresh_request_id.wrapping_add(1);
request_id
}
fn send_rate_limit_refresh_request(&self, request_id: u64) {
self.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
});
}
fn request_rate_limit_refresh(&mut self) -> u64 {
let request_id = self.next_rate_limit_refresh_request_id();
self.send_rate_limit_refresh_request(request_id);
request_id
}
fn missing_usage_based_workspace_rate_limit_snapshot(&self) -> bool {
self.should_prefetch_rate_limits()
&& self.plan_type.is_some()
&& !self.rate_limit_snapshots_by_limit_id.contains_key("codex")
}
fn should_prompt_workspace_owner_notification(&self) -> bool {
if self.notify_workspace_owner_in_flight {
return false;
}
if matches!(
self.usage_based_workspace_rate_limit_state(),
Some(UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted)
) {
return true;
}
let is_workspace_owner = self.current_is_workspace_owner();
if !self.plan_type.is_some_and(is_usage_based_workspace_plan)
|| is_workspace_owner == Some(true)
{
return false;
}
let block_kind = self.usage_based_workspace_block_kind();
block_kind != Some(UsageBasedWorkspaceBlockKind::SpendCapReached)
}
fn maybe_show_pending_workspace_owner_notification_prompt(&mut self) {
if !self.pending_workspace_owner_notification_prompt {
return;
}
if self.notify_workspace_owner_in_flight {
self.pending_workspace_owner_notification_prompt = false;
return;
}
if !self.bottom_pane.no_modal_or_popup_active() {
return;
}
match self.usage_based_workspace_rate_limit_state() {
Some(UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted) => {
self.pending_workspace_owner_notification_prompt = false;
self.open_workspace_owner_notification_prompt();
}
Some(_) => {
self.pending_workspace_owner_notification_prompt = false;
}
None => {
if self.usage_based_workspace_block_kind()
== Some(UsageBasedWorkspaceBlockKind::CreditsDepleted)
&& self.current_is_workspace_owner().is_none()
{
return;
}
if !self.missing_usage_based_workspace_rate_limit_snapshot() {
self.pending_workspace_owner_notification_prompt = false;
}
}
}
}
fn open_workspace_owner_notification_prompt(&mut self) {
let items = vec![
SelectionItem {
name: "Yes".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))),
actions: vec![Box::new(|tx| tx.send(AppEvent::NotifyWorkspaceOwner))],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "No".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))),
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some(WORKSPACE_OWNER_NOTIFICATION_TITLE.to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx: Some(1),
..Default::default()
});
}
pub(crate) fn start_notify_workspace_owner(&mut self) {
self.notify_workspace_owner_in_flight = true;
self.pending_workspace_owner_notification_prompt = false;
}
pub(crate) fn finish_notify_workspace_owner(
&mut self,
result: Result<AddCreditsNudgeEmailStatus, String>,
) {
self.notify_workspace_owner_in_flight = false;
self.pending_workspace_owner_notification_prompt = false;
match result {
Ok(AddCreditsNudgeEmailStatus::Sent) => {
self.add_info_message("Workspace owner notified.".to_string(), /*hint*/ None);
}
Ok(AddCreditsNudgeEmailStatus::CooldownActive) => {
self.add_info_message(
"Workspace owner was already notified recently.".to_string(),
/*hint*/ None,
);
}
Err(_) => {
self.add_error_message(
"Could not notify your workspace owner. Please try again.".to_string(),
);
}
}
}
pub(crate) fn update_account_state(
&mut self,
status_account_display: Option<StatusAccountDisplay>,
workspace_role: Option<AppServerWorkspaceRole>,
is_workspace_owner: Option<bool>,
plan_type: Option<PlanType>,
has_chatgpt_account: bool,
) {
self.status_account_display = status_account_display;
self.workspace_role = workspace_role;
self.is_workspace_owner = is_workspace_owner;
self.plan_type = plan_type;
self.has_chatgpt_account = has_chatgpt_account;
self.maybe_show_pending_workspace_owner_notification_prompt();
self.bottom_pane
.set_connectors_enabled(self.connectors_enabled());
}
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests/app_server.rs
assertion_line: 608
expression: rendered
---
■ Your workspace is out of credits. Request more from your workspace owner? [y/N]
-1
View File
@@ -128,7 +128,6 @@ pub(super) use codex_protocol::parse_command::ParsedCommand;
pub(super) use codex_protocol::plan_tool::PlanItemArg;
pub(super) use codex_protocol::plan_tool::StepStatus;
pub(super) use codex_protocol::plan_tool::UpdatePlanArgs;
pub(super) use codex_protocol::protocol::AddCreditsNudgeEmailStatus;
pub(super) use codex_protocol::protocol::AgentMessageDeltaEvent;
pub(super) use codex_protocol::protocol::AgentMessageEvent;
pub(super) use codex_protocol::protocol::AgentReasoningDeltaEvent;
@@ -569,117 +569,6 @@ async fn live_app_server_server_overloaded_error_renders_warning() {
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn live_app_server_usage_limit_error_shows_notify_owner_hint() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: false,
unlimited: false,
balance: None,
}),
spend_control: None,
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
chat.handle_server_notification(
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded.into()),
additional_details: None,
},
will_retry: false,
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
}),
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Your workspace is out of credits."),
"expected usage-limit error, got {rendered:?}"
);
assert!(
rendered.contains("Request more from your workspace owner? [y/N]"),
"expected workspace-owner prompt, got {rendered:?}"
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Request more credits from your workspace owner?"),
"expected workspace-owner confirmation popup, got {popup:?}"
);
assert_chatwidget_snapshot!(
"live_app_server_usage_limit_error_shows_notify_owner_hint",
rendered
);
}
#[tokio::test]
async fn live_app_server_usage_limit_error_shows_spend_cap_hint() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
}),
spend_control: Some(codex_protocol::protocol::SpendControlSnapshot { reached: true }),
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
chat.handle_server_notification(
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded.into()),
additional_details: None,
},
will_retry: false,
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
}),
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Your workspace has reached its spend cap."),
"expected spend-cap error, got {rendered:?}"
);
assert!(
!rendered.contains("Request more from your workspace owner? [y/N]"),
"expected spend-cap guidance instead of workspace-owner prompt, got {rendered:?}"
);
}
#[tokio::test]
async fn live_app_server_invalid_thread_name_update_is_ignored() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -104,7 +104,6 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
}
}
@@ -190,8 +189,6 @@ pub(super) async fn make_chatwidget_manual(
current_collaboration_mode,
active_collaboration_mask,
has_chatgpt_account: false,
workspace_role: None,
is_workspace_owner: None,
model_catalog,
session_telemetry,
session_header: SessionHeader::new(resolved_model.clone()),
@@ -202,8 +199,6 @@ pub(super) async fn make_chatwidget_manual(
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
plan_type: None,
notify_workspace_owner_in_flight: false,
pending_workspace_owner_notification_prompt: false,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(),
@@ -1245,8 +1245,6 @@ async fn collaboration_modes_defaults_to_code_on_startup() {
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
status_account_display: None,
initial_workspace_role: None,
initial_is_workspace_owner: None,
initial_plan_type: None,
model: Some(resolved_model.clone()),
startup_tooltip_override: None,
@@ -71,8 +71,6 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
status_account_display: None,
initial_workspace_role: None,
initial_is_workspace_owner: None,
initial_plan_type: None,
model: Some(resolved_model.clone()),
startup_tooltip_override: None,
@@ -130,8 +130,6 @@ async fn helpers_are_available_and_do_not_panic() {
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
status_account_display: None,
initial_workspace_role: None,
initial_is_workspace_owner: None,
initial_plan_type: None,
model: Some(resolved_model),
startup_tooltip_override: None,
@@ -236,7 +234,6 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
unlimited: false,
balance: Some("17.5".to_string()),
}),
spend_control: None,
plan_type: None,
}));
let initial_balance = chat
@@ -256,7 +253,6 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
}));
@@ -295,7 +291,6 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: None,
}),
credits: None,
spend_control: None,
plan_type: Some(PlanType::Plus),
}));
assert_eq!(chat.plan_type, Some(PlanType::Plus));
@@ -314,7 +309,6 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: Some(234),
}),
credits: None,
spend_control: None,
plan_type: Some(PlanType::Pro),
}));
assert_eq!(chat.plan_type, Some(PlanType::Pro));
@@ -333,7 +327,6 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: Some(567),
}),
credits: None,
spend_control: None,
plan_type: None,
}));
assert_eq!(chat.plan_type, Some(PlanType::Pro));
@@ -357,7 +350,6 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
unlimited: false,
balance: Some("5.00".to_string()),
}),
spend_control: None,
plan_type: Some(PlanType::Pro),
}));
@@ -371,7 +363,6 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(PlanType::Pro),
}));
@@ -396,101 +387,6 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
assert!(other.credits.is_none());
}
#[tokio::test]
async fn core_usage_limit_error_shows_notify_owner_hint() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: false,
unlimited: false,
balance: None,
}),
spend_control: None,
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Your workspace is out of credits."),
"expected usage-limit error, got {rendered:?}"
);
assert!(
rendered.contains("Request more from your workspace owner? [y/N]"),
"expected workspace-owner prompt, got {rendered:?}"
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Request more credits from your workspace owner?"),
"expected workspace-owner confirmation popup, got {popup:?}"
);
}
#[tokio::test]
async fn core_usage_limit_error_shows_spend_cap_hint_when_reached() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
}),
spend_control: Some(codex_protocol::protocol::SpendControlSnapshot { reached: true }),
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Your workspace has reached its spend cap."),
"expected spend-cap hint, got {rendered:?}"
);
assert!(
!rendered.contains("Request more from your workspace owner? [y/N]"),
"expected spend-cap hint instead of workspace-owner prompt, got {rendered:?}"
);
}
#[tokio::test]
async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() {
let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await;
@@ -519,7 +415,6 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
}));
@@ -120,212 +120,3 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() {
chat.finish_status_rate_limit_refresh(second_request_id);
assert!(chat.refreshing_status_outputs.is_empty());
}
#[tokio::test]
async fn usage_limit_error_requests_background_rate_limit_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })),
"expected usage-limit errors to trigger a background rate-limit refresh; events: {events:?}"
);
}
#[tokio::test]
async fn usage_limit_error_opens_workspace_owner_prompt_after_rate_limits_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })),
"expected usage-limit errors to request a refresh when rate limits are missing; events: {events:?}"
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: false,
unlimited: false,
balance: None,
}),
spend_control: None,
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Request more credits from your workspace owner?"),
"expected workspace-owner prompt after refresh, got: {popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::NotifyWorkspaceOwner));
}
#[tokio::test]
async fn usage_limit_error_opens_workspace_owner_prompt_after_async_workspace_role() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
/*is_workspace_owner*/ None,
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })),
"expected usage-limit errors to request a refresh when rate limits are missing; events: {events:?}"
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: false,
unlimited: false,
balance: None,
}),
spend_control: None,
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
!popup.contains("Request more credits from your workspace owner?"),
"expected no prompt before the async workspace role update, got: {popup}"
);
chat.update_account_state(
/*status_account_display*/ None,
Some(AppServerWorkspaceRole::StandardUser),
/*is_workspace_owner*/ None,
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Request more credits from your workspace owner?"),
"expected workspace-owner prompt after async workspace role, got: {popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::NotifyWorkspaceOwner));
}
#[tokio::test]
async fn notify_workspace_owner_success_adds_confirmation_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.start_notify_workspace_owner();
chat.finish_notify_workspace_owner(Ok(AddCreditsNudgeEmailStatus::Sent));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one confirmation message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Workspace owner notified."),
"expected success message, got {rendered:?}"
);
assert!(
!chat.notify_workspace_owner_in_flight,
"notify-owner state should clear after success"
);
}
#[tokio::test]
async fn notify_workspace_owner_cooldown_adds_info_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.start_notify_workspace_owner();
chat.finish_notify_workspace_owner(Ok(AddCreditsNudgeEmailStatus::CooldownActive));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one cooldown message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Workspace owner was already notified recently."),
"expected cooldown message, got {rendered:?}"
);
assert!(
!chat.notify_workspace_owner_in_flight,
"notify-owner state should clear after cooldown"
);
}
#[tokio::test]
async fn notify_workspace_owner_error_adds_retry_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.start_notify_workspace_owner();
chat.finish_notify_workspace_owner(Err("backend failed".to_string()));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one error message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Could not notify your workspace owner. Please try again."),
"expected retry message, got {rendered:?}"
);
assert!(
!chat.notify_workspace_owner_in_flight,
"notify-owner state should clear after errors"
);
}
+1 -8
View File
@@ -2166,17 +2166,10 @@ pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHist
}
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
new_error_event_with_hint(message, /*hint*/ None)
}
pub(crate) fn new_error_event_with_hint(message: String, hint: Option<String>) -> PlainHistoryCell {
// Use a hair space (U+200A) to create a subtle, near-invisible separation
// before the text. VS16 is intentionally omitted to keep spacing tighter
// in terminals like Ghostty.
let mut lines: Vec<Line<'static>> = vec![vec![format!("{message}").red()].into()];
if let Some(hint) = hint {
lines.push(vec![" ".into(), hint.dark_gray()].into());
}
let lines: Vec<Line<'static>> = vec![vec![format!("{message}").red()].into()];
PlainHistoryCell { lines }
}
+1 -3
View File
@@ -891,9 +891,7 @@ pub async fn run_main(
// use RUST_LOG env var, default to info for codex crates.
let env_filter = || {
EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"codex_core=info,codex_tui=info,codex_rmcp_client=info,codex_app_server=info",
)
EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info")
})
};
+1 -66
View File
@@ -16,7 +16,6 @@ use chrono::Utc;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlSnapshot as CoreSpendControlSnapshot;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "";
@@ -99,8 +98,6 @@ pub(crate) struct RateLimitSnapshotDisplay {
pub secondary: Option<RateLimitWindowDisplay>,
/// Optional credits metadata when available.
pub credits: Option<CreditsSnapshotDisplay>,
/// Optional spend-control metadata for usage-based workspace plans.
pub spend_control: Option<SpendControlSnapshotDisplay>,
}
/// Display-ready credits state extracted from protocol snapshots.
@@ -114,13 +111,6 @@ pub(crate) struct CreditsSnapshotDisplay {
pub balance: Option<String>,
}
/// Display-ready spend-control state extracted from protocol snapshots.
#[derive(Debug, Clone)]
pub(crate) struct SpendControlSnapshotDisplay {
/// Whether a workspace spend cap is currently blocking usage.
pub reached: bool,
}
/// Converts a protocol snapshot into UI-friendly display data.
///
/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly
@@ -150,10 +140,6 @@ pub(crate) fn rate_limit_snapshot_display_for_limit(
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
spend_control: snapshot
.spend_control
.as_ref()
.map(SpendControlSnapshotDisplay::from),
}
}
@@ -167,14 +153,6 @@ impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay {
}
}
impl From<&CoreSpendControlSnapshot> for SpendControlSnapshotDisplay {
fn from(value: &CoreSpendControlSnapshot) -> Self {
Self {
reached: value.reached,
}
}
}
/// Builds display rows from a snapshot and marks stale data by capture age.
///
/// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make
@@ -197,7 +175,7 @@ pub(crate) fn compose_rate_limit_data_many(
return StatusRateLimitData::Missing;
}
let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(4));
let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3));
let mut stale = false;
for snapshot in snapshots {
@@ -285,12 +263,6 @@ pub(crate) fn compose_rate_limit_data_many(
});
}
if let Some(spend_control) = snapshot.spend_control.as_ref()
&& let Some(row) = spend_control_status_row(spend_control)
{
rows.push(row);
}
if let Some(credits) = snapshot.credits.as_ref()
&& let Some(row) = credit_status_row(credits)
{
@@ -350,15 +322,6 @@ fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option<StatusRateLimit
})
}
fn spend_control_status_row(
spend_control: &SpendControlSnapshotDisplay,
) -> Option<StatusRateLimitRow> {
spend_control.reached.then(|| StatusRateLimitRow {
label: "Spend cap".to_string(),
value: StatusRateLimitValue::Text("Reached".to_string()),
})
}
fn format_credit_balance(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
@@ -386,7 +349,6 @@ mod tests {
use super::CreditsSnapshotDisplay;
use super::RateLimitSnapshotDisplay;
use super::RateLimitWindowDisplay;
use super::SpendControlSnapshotDisplay;
use super::StatusRateLimitData;
use super::compose_rate_limit_data_many;
use chrono::Local;
@@ -413,7 +375,6 @@ mod tests {
unlimited: false,
balance: Some("25".to_string()),
}),
spend_control: None,
};
let other = RateLimitSnapshotDisplay {
limit_name: "codex-other".to_string(),
@@ -425,7 +386,6 @@ mod tests {
unlimited: false,
balance: Some("99".to_string()),
}),
spend_control: None,
};
let rows = match compose_rate_limit_data_many(&[codex, other], now) {
@@ -463,7 +423,6 @@ mod tests {
window_minutes: None,
}),
credits: None,
spend_control: None,
};
let rows = match compose_rate_limit_data_many(&[other], now) {
@@ -480,28 +439,4 @@ mod tests {
]
);
}
#[test]
fn spend_cap_reached_renders_status_row_without_windows() {
let now = Local::now();
let snapshot = RateLimitSnapshotDisplay {
limit_name: "codex".to_string(),
captured_at: now,
primary: None,
secondary: None,
credits: Some(CreditsSnapshotDisplay {
has_credits: true,
unlimited: false,
balance: None,
}),
spend_control: Some(SpendControlSnapshotDisplay { reached: true }),
};
let rows = match compose_rate_limit_data_many(&[snapshot], now) {
StatusRateLimitData::Available(rows) => rows,
other => panic!("unexpected status: {other:?}"),
};
let labels: Vec<String> = rows.iter().map(|row| row.label.clone()).collect();
assert_eq!(labels, vec!["Spend cap".to_string()]);
}
}
@@ -1,22 +0,0 @@
---
source: tui/src/status/tests.rs
assertion_line: 963
expression: sanitized
---
/status
╭───────────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
│ information on rate limits and credits │
│ │
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
│ Directory: [[workspace]] │
│ Permissions: Custom (read-only, on-request) │
│ Agents.md: <none> │
│ │
│ Token usage: 750 total (500 input + 250 output) │
│ Context window: 100% left (750 used / 272K) │
│ Spend cap: Reached │
╰───────────────────────────────────────────────────────────────────────╯
-75
View File
@@ -136,7 +136,6 @@ async fn status_snapshot_includes_reasoning_details() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)),
}),
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -320,7 +319,6 @@ async fn status_snapshot_includes_monthly_limit() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -372,7 +370,6 @@ async fn status_snapshot_shows_unlimited_credits() {
unlimited: true,
balance: None,
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -422,7 +419,6 @@ async fn status_snapshot_shows_positive_credits() {
unlimited: false,
balance: Some("12.5".to_string()),
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -472,7 +468,6 @@ async fn status_snapshot_hides_zero_credits() {
unlimited: false,
balance: Some("0".to_string()),
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -520,7 +515,6 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
unlimited: true,
balance: None,
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -626,7 +620,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -740,7 +733,6 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)),
}),
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -811,7 +803,6 @@ async fn status_snapshot_includes_credits_and_limits() {
unlimited: false,
balance: Some("37.5".to_string()),
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -865,69 +856,6 @@ async fn status_snapshot_shows_unavailable_limits_message() {
primary: None,
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
};
let captured_at = chrono::Local
.with_ymd_and_hms(2024, 6, 7, 8, 9, 10)
.single()
.expect("timestamp");
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = codex_core::test_support::get_model_offline(config.model.as_deref());
let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
account_display.as_ref(),
Some(&token_info),
&usage,
&None,
/*thread_name*/ None,
/*forked_from*/ None,
Some(&rate_display),
None,
captured_at,
&model_slug,
/*collaboration_mode*/ None,
/*reasoning_effort_override*/ None,
);
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
#[tokio::test]
async fn status_snapshot_shows_spend_cap_reached_message() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = PathBuf::from("/workspace/tests").abs();
let account_display = test_status_account_display();
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
output_tokens: 250,
reasoning_output_tokens: 0,
total_tokens: 750,
};
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
}),
spend_control: Some(codex_protocol::protocol::SpendControlSnapshot { reached: true }),
plan_type: None,
};
let captured_at = chrono::Local
@@ -984,7 +912,6 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
primary: None,
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
};
let captured_at = chrono::Local
@@ -1055,7 +982,6 @@ async fn status_snapshot_shows_stale_limits_message() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)),
}),
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -1126,7 +1052,6 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
unlimited: false,
balance: Some("80".to_string()),
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);