diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7da01e511..f09f5d3a3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1366,6 +1366,16 @@ 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" @@ -1898,6 +1908,7 @@ dependencies = [ "bm25", "chrono", "clap", + "codex-account", "codex-analytics", "codex-api", "codex-app-server-protocol", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 32ae50bfb..31dfeaf51 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "account", "analytics", "backend-client", "ansi-escape", @@ -104,6 +105,7 @@ 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" } diff --git a/codex-rs/account/BUILD.bazel b/codex-rs/account/BUILD.bazel new file mode 100644 index 000000000..b35b3c390 --- /dev/null +++ b/codex-rs/account/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "account", + crate_name = "codex_account", +) diff --git a/codex-rs/account/Cargo.toml b/codex-rs/account/Cargo.toml new file mode 100644 index 000000000..f1b8f8e8b --- /dev/null +++ b/codex-rs/account/Cargo.toml @@ -0,0 +1,19 @@ +[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 } diff --git a/codex-rs/account/src/lib.rs b/codex-rs/account/src/lib.rs new file mode 100644 index 000000000..02368eafe --- /dev/null +++ b/codex-rs/account/src/lib.rs @@ -0,0 +1,121 @@ +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 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, + pub is_workspace_owner: Option, +} + +#[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, + auth_manager: &AuthManager, +) -> Result { + 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, + auth: &CodexAuth, +) -> Result { + 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 { + 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) +} diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 931b727c8..335958d49 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -1287,6 +1287,8 @@ 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"), @@ -1395,6 +1397,8 @@ 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"), @@ -1448,6 +1452,8 @@ mod tests { first_response, GetAccountResponse { account: None, + workspace_role: None, + is_workspace_owner: None, requires_openai_auth: false, } ); @@ -1467,6 +1473,8 @@ mod tests { AccountUpdatedNotification { auth_mode: None, plan_type: None, + workspace_role: None, + is_workspace_owner: None, }, )) .expect("notification should serialize"), diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index c5dc32fb9..3372758cd 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2562,6 +2562,17 @@ ], "type": "object" }, + "ThreadAddCreditsNudgeEmailParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, "ThreadArchiveParams": { "properties": { "threadId": { @@ -3809,6 +3820,30 @@ "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": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index d598b2cd5..4f5410c78 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -51,6 +51,12 @@ } ] }, + "isWorkspaceOwner": { + "type": [ + "boolean", + "null" + ] + }, "planType": { "anyOf": [ { @@ -60,10 +66,89 @@ "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": { @@ -2045,6 +2130,16 @@ "type": "null" } ] + }, + "spendControl": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlSnapshot" + }, + { + "type": "null" + } + ] } }, "type": "object" @@ -2244,6 +2339,17 @@ "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": [ { @@ -4028,6 +4134,14 @@ "samplePaths" ], "type": "object" + }, + "WorkspaceRole": { + "enum": [ + "account-owner", + "account-admin", + "standard-user" + ], + "type": "string" } }, "description": "Notification sent from the server to the client.", @@ -4655,6 +4769,26 @@ "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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index d3447132a..e4ced5070 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -482,6 +482,30 @@ "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": { @@ -4034,6 +4058,26 @@ "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": { @@ -4921,6 +4965,12 @@ } ] }, + "isWorkspaceOwner": { + "type": [ + "boolean", + "null" + ] + }, "planType": { "anyOf": [ { @@ -4930,11 +4980,92 @@ "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": { @@ -8011,8 +8142,24 @@ } ] }, + "isWorkspaceOwner": { + "type": [ + "boolean", + "null" + ] + }, "requiresOpenaiAuth": { "type": "boolean" + }, + "workspaceRole": { + "anyOf": [ + { + "$ref": "#/definitions/v2/WorkspaceRole" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -10396,6 +10543,16 @@ "type": "null" } ] + }, + "spendControl": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SpendControlSnapshot" + }, + { + "type": "null" + } + ] } }, "type": "object" @@ -12219,6 +12376,17 @@ "title": "SkillsListResponse", "type": "object" }, + "SpendControlSnapshot": { + "properties": { + "reached": { + "type": "boolean" + } + }, + "required": [ + "reached" + ], + "type": "object" + }, "SubAgentSource": { "oneOf": [ { @@ -12510,6 +12678,24 @@ ], "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": { @@ -15418,6 +15604,14 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorkspaceRole": { + "enum": [ + "account-owner", + "account-admin", + "standard-user" + ], + "type": "string" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6f2efa4e1..d1f3cbda5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -100,6 +100,12 @@ } ] }, + "isWorkspaceOwner": { + "type": [ + "boolean", + "null" + ] + }, "planType": { "anyOf": [ { @@ -109,11 +115,92 @@ "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": { @@ -1057,6 +1144,30 @@ "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": { @@ -4770,8 +4881,24 @@ } ] }, + "isWorkspaceOwner": { + "type": [ + "boolean", + "null" + ] + }, "requiresOpenaiAuth": { "type": "boolean" + }, + "workspaceRole": { + "anyOf": [ + { + "$ref": "#/definitions/WorkspaceRole" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -7199,6 +7326,16 @@ "type": "null" } ] + }, + "spendControl": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlSnapshot" + }, + { + "type": "null" + } + ] } }, "type": "object" @@ -9208,6 +9345,26 @@ "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": { @@ -10074,6 +10231,17 @@ "title": "SkillsListResponse", "type": "object" }, + "SpendControlSnapshot": { + "properties": { + "reached": { + "type": "boolean" + } + }, + "required": [ + "reached" + ], + "type": "object" + }, "SubAgentSource": { "oneOf": [ { @@ -10365,6 +10533,24 @@ ], "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": { @@ -13273,6 +13459,14 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorkspaceRole": { + "enum": [ + "account-owner", + "account-admin", + "standard-user" + ], + "type": "string" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json index d1812f069..8ec964265 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountRateLimitsUpdatedNotification.json @@ -91,6 +91,16 @@ "type": "null" } ] + }, + "spendControl": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlSnapshot" + }, + { + "type": "null" + } + ] } }, "type": "object" @@ -120,6 +130,17 @@ "usedPercent" ], "type": "object" + }, + "SpendControlSnapshot": { + "properties": { + "reached": { + "type": "boolean" + } + }, + "required": [ + "reached" + ], + "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index f2cf7cb3a..ae37fc002 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -42,6 +42,14 @@ "unknown" ], "type": "string" + }, + "WorkspaceRole": { + "enum": [ + "account-owner", + "account-admin", + "standard-user" + ], + "type": "string" } }, "properties": { @@ -55,6 +63,12 @@ } ] }, + "isWorkspaceOwner": { + "type": [ + "boolean", + "null" + ] + }, "planType": { "anyOf": [ { @@ -64,6 +78,16 @@ "type": "null" } ] + }, + "workspaceRole": { + "anyOf": [ + { + "$ref": "#/definitions/WorkspaceRole" + }, + { + "type": "null" + } + ] } }, "title": "AccountUpdatedNotification", diff --git a/codex-rs/app-server-protocol/schema/json/v2/AddCreditsNudgeEmailNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AddCreditsNudgeEmailNotification.json new file mode 100644 index 000000000..19fee5c5e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/AddCreditsNudgeEmailNotification.json @@ -0,0 +1,73 @@ +{ + "$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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 23f7d3cfd..b89007e93 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -91,6 +91,16 @@ "type": "null" } ] + }, + "spendControl": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlSnapshot" + }, + { + "type": "null" + } + ] } }, "type": "object" @@ -120,6 +130,17 @@ "usedPercent" ], "type": "object" + }, + "SpendControlSnapshot": { + "properties": { + "reached": { + "type": "boolean" + } + }, + "required": [ + "reached" + ], + "type": "object" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index acaa77918..a72db5b29 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -60,6 +60,14 @@ "unknown" ], "type": "string" + }, + "WorkspaceRole": { + "enum": [ + "account-owner", + "account-admin", + "standard-user" + ], + "type": "string" } }, "properties": { @@ -73,8 +81,24 @@ } ] }, + "isWorkspaceOwner": { + "type": [ + "boolean", + "null" + ] + }, "requiresOpenaiAuth": { "type": "boolean" + }, + "workspaceRole": { + "anyOf": [ + { + "$ref": "#/definitions/WorkspaceRole" + }, + { + "type": "null" + } + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailParams.json new file mode 100644 index 000000000..75db04f4d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadAddCreditsNudgeEmailParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailResponse.json new file mode 100644 index 000000000..1def4b406 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadAddCreditsNudgeEmailResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadAddCreditsNudgeEmailResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index d12599d7a..95432a909 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -43,6 +43,7 @@ import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; +import type { ThreadAddCreditsNudgeEmailParams } from "./v2/ThreadAddCreditsNudgeEmailParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; @@ -65,4 +66,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/addCreditsNudgeEmail", id: RequestId, params: ThreadAddCreditsNudgeEmailParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index a98591413..e10512cb9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -6,6 +6,7 @@ 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"; @@ -58,4 +59,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": "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": "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 }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts index 84bf626e0..eee18bc9c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AccountUpdatedNotification.ts @@ -3,5 +3,6 @@ // 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, }; +export type AccountUpdatedNotification = { authMode: AuthMode | null, planType: PlanType | null, workspaceRole: WorkspaceRole | null, isWorkspaceOwner: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailNotification.ts new file mode 100644 index 000000000..855397899 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailNotification.ts @@ -0,0 +1,6 @@ +// 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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailResult.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailResult.ts new file mode 100644 index 000000000..3aed1d1a8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailResult.ts @@ -0,0 +1,5 @@ +// 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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountResponse.ts index 83da4f4e5..b9d5d89da 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountResponse.ts @@ -2,5 +2,6 @@ // 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, requiresOpenaiAuth: boolean, }; +export type GetAccountResponse = { account: Account | null, workspaceRole: WorkspaceRole | null, isWorkspaceOwner: boolean | null, requiresOpenaiAuth: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts index 0c2ebe189..4aea9d317 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitSnapshot.ts @@ -4,5 +4,6 @@ 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, planType: PlanType | null, }; +export type RateLimitSnapshot = { limitId: string | null, limitName: string | null, primary: RateLimitWindow | null, secondary: RateLimitWindow | null, credits: CreditsSnapshot | null, spendControl: SpendControlSnapshot | null, planType: PlanType | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlSnapshot.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlSnapshot.ts new file mode 100644 index 000000000..ba812459d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SpendControlSnapshot.ts @@ -0,0 +1,5 @@ +// 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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailParams.ts new file mode 100644 index 000000000..df4fc767b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailParams.ts @@ -0,0 +1,5 @@ +// 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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailResponse.ts new file mode 100644 index 000000000..626750e68 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadAddCreditsNudgeEmailResponse.ts @@ -0,0 +1,5 @@ +// 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; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceRole.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceRole.ts new file mode 100644 index 000000000..17846b49b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceRole.ts @@ -0,0 +1,5 @@ +// 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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 7f95634e9..d8dd15c97 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -4,6 +4,8 @@ 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"; @@ -265,12 +267,15 @@ 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"; @@ -351,4 +356,5 @@ 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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 731288272..14eb1f5f3 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -296,6 +296,10 @@ 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, @@ -991,6 +995,7 @@ 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), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index de96ef2bf..66ac1a602 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -82,6 +82,7 @@ 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; @@ -1738,11 +1739,27 @@ 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, + pub workspace_role: Option, + pub is_workspace_owner: Option, pub requires_openai_auth: bool, } @@ -3019,6 +3036,18 @@ 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/")] @@ -3656,6 +3685,8 @@ pub struct Thread { pub struct AccountUpdatedNotification { pub auth_mode: Option, pub plan_type: Option, + pub workspace_role: Option, + pub is_workspace_owner: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -6227,6 +6258,24 @@ 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/")] @@ -6236,6 +6285,7 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, + pub spend_control: Option, pub plan_type: Option, } @@ -6247,6 +6297,7 @@ impl From 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, } } @@ -6292,6 +6343,21 @@ impl From 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 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/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 609a792f3..eeaf9ef23 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -147,6 +147,7 @@ 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 agent’s 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". @@ -456,6 +457,15 @@ 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: @@ -1356,19 +1366,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, and can be inferred 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, 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`. - **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 current account info; optionally refresh tokens. +- `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/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` when available. +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType`, `workspaceRole`, and `isWorkspaceOwner`. - `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? }`. @@ -1385,16 +1395,19 @@ Request: Response examples: ```json -{ "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 } } +{ "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 ``` 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 @@ -1413,7 +1426,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 } } + { "method": "account/updated", "params": { "authMode": "apikey", "planType": null, "workspaceRole": null, "isWorkspaceOwner": null } } ``` ### 3) Log in with ChatGPT (browser flow) @@ -1427,7 +1440,7 @@ Field notes: 3. Wait for notifications: ```json { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } - { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } + { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus", "workspaceRole": "account-owner", "isWorkspaceOwner": true } } ``` ### 4) Log in with ChatGPT (device code flow) @@ -1441,7 +1454,7 @@ Field notes: 3. Wait for notifications: ```json { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } - { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus" } } + { "method": "account/updated", "params": { "authMode": "chatgpt", "planType": "plus", "workspaceRole": "account-owner", "isWorkspaceOwner": true } } ``` ### 5) Cancel a ChatGPT login @@ -1456,7 +1469,7 @@ Field notes: ```json { "method": "account/logout", "id": 6 } { "id": 6, "result": {} } -{ "method": "account/updated", "params": { "authMode": null, "planType": null } } +{ "method": "account/updated", "params": { "authMode": null, "planType": null, "workspaceRole": null, "isWorkspaceOwner": null } } ``` ### 7) Rate limits (ChatGPT) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 8a98965ba..cdd63b0f3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -13,6 +13,8 @@ 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; @@ -117,6 +119,7 @@ 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; @@ -223,6 +226,26 @@ 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 @@ -3968,6 +3991,7 @@ mod tests { unlimited: false, balance: Some("5".to_string()), }), + spend_control: None, plan_type: None, }; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 96eba2f63..f37f89662 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -116,6 +116,8 @@ 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; @@ -184,6 +186,7 @@ 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; @@ -200,6 +203,7 @@ 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; @@ -486,6 +490,96 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) log_db: Option, } +async fn resolve_workspace_role_and_owner_for_auth( + chatgpt_base_url: &str, + auth: Option<&CodexAuth>, +) -> (Option, Option) { + 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, + account_email: Option, + chatgpt_user_id: Option, +} + +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, Option) { + (None, auth.and_then(CodexAuth::is_workspace_owner)) +} + +fn account_updated_notification_for_auth( + auth: Option<&CodexAuth>, + workspace_role: Option, + is_workspace_owner: Option, +) -> 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, + auth_manager: Arc, + chatgpt_base_url: String, + auth: Option, +) { + 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(); @@ -498,10 +592,18 @@ impl CodexMessageProcessor { fn current_account_updated_notification(&self) -> AccountUpdatedNotification { let auth = self.auth_manager.auth_cached(); - AccountUpdatedNotification { - auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), - plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), - } + 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) { + spawn_live_workspace_role_update_for_auth( + self.outgoing.clone(), + self.auth_manager.clone(), + self.config.chatgpt_base_url.clone(), + auth, + ); } async fn load_thread( @@ -785,6 +887,10 @@ 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; @@ -1104,6 +1210,7 @@ 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; @@ -1224,7 +1331,7 @@ impl CodexMessageProcessor { replace_cloud_requirements_loader( cloud_requirements.as_ref(), auth_manager.clone(), - chatgpt_base_url, + chatgpt_base_url.clone(), codex_home, ); sync_default_client_residency_requirement( @@ -1233,17 +1340,27 @@ impl CodexMessageProcessor { ) .await; - // Notify clients with the actual current auth mode. + // Notify clients with the actual current auth mode immediately; the + // live workspace role is fetched below without delaying this update. let auth = auth_manager.auth_cached(); - 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), - }; + 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, + ); 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. @@ -1338,7 +1455,7 @@ impl CodexMessageProcessor { replace_cloud_requirements_loader( cloud_requirements.as_ref(), auth_manager.clone(), - chatgpt_base_url, + chatgpt_base_url.clone(), codex_home, ); sync_default_client_residency_requirement( @@ -1348,15 +1465,24 @@ impl CodexMessageProcessor { .await; let auth = auth_manager.auth_cached(); - 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), - }; + 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, + ); 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; @@ -1505,6 +1631,7 @@ 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, JSONRPCErrorError> { @@ -1542,6 +1669,8 @@ 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)) @@ -1640,13 +1769,16 @@ 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 account = match self.auth_manager.auth_cached() { + let auth = self.auth_manager.auth_cached(); + let account = match auth.as_ref() { Some(auth) => match auth.auth_mode() { CoreAuthMode::ApiKey => Some(Account::ApiKey {}), CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => { @@ -1673,12 +1805,17 @@ 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) { @@ -1696,6 +1833,11 @@ 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; } } @@ -3396,6 +3538,40 @@ 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, diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index d4e4bde06..37e704663 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -735,6 +735,7 @@ mod tests { }), secondary: None, credits: None, + spend_control: None, plan_type: Some(PlanType::Plus), }, }); @@ -754,6 +755,7 @@ mod tests { }, "secondary": null, "credits": null, + "spendControl": null, "planType": "plus" } }, @@ -769,6 +771,8 @@ 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); @@ -777,7 +781,9 @@ mod tests { "method": "account/updated", "params": { "authMode": "apikey", - "planType": null + "planType": null, + "workspaceRole": null, + "isWorkspaceOwner": null }, }), serde_json::to_value(jsonrpc_notification) diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 99334f077..78bc42a53 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -60,6 +60,11 @@ 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) -> Self { self.claims.email = Some(email.into()); self @@ -82,6 +87,7 @@ pub struct ChatGptIdTokenClaims { pub plan_type: Option, pub chatgpt_user_id: Option, pub chatgpt_account_id: Option, + pub is_org_owner: Option, } impl ChatGptIdTokenClaims { @@ -108,6 +114,11 @@ 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 { @@ -126,6 +137,9 @@ pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { 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(), diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 03c2284b8..013096621 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -58,6 +58,7 @@ 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; @@ -414,6 +415,16 @@ 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 { + 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, diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 3c88bcb7a..dc8f7eba3 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -28,6 +28,7 @@ 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; @@ -55,6 +56,7 @@ struct CreateConfigTomlParams { forced_workspace_id: Option, requires_openai_auth: Option, base_url: Option, + chatgpt_base_url: Option, } fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> { @@ -62,6 +64,9 @@ 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 { @@ -82,6 +87,7 @@ 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} @@ -122,6 +128,49 @@ 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")) @@ -221,10 +270,12 @@ 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() @@ -262,6 +313,20 @@ 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 { @@ -281,6 +346,8 @@ 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, } ); @@ -348,6 +415,8 @@ 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, } ); @@ -1505,6 +1574,8 @@ 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); @@ -1539,6 +1610,8 @@ 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); @@ -1559,7 +1632,8 @@ async fn get_account_with_chatgpt() -> Result<()> { codex_home.path(), ChatGptAuthFixture::new("access-chatgpt") .email("user@example.com") - .plan_type("pro"), + .plan_type("pro") + .is_org_owner(/*is_org_owner*/ true), AuthCredentialsStoreMode::File, )?; @@ -1583,12 +1657,190 @@ 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()?; @@ -1625,6 +1877,8 @@ 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); diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 1bb74ccc6..4723c6708 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -32,6 +32,7 @@ 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; diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 203d66494..612b62742 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -133,7 +133,10 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { } } } - ] + ], + "spend_control": { + "reached": true + } }); Mock::given(method("GET")) @@ -172,6 +175,7 @@ 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( @@ -192,6 +196,9 @@ 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), }, ), @@ -207,6 +214,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { }), secondary: None, credits: None, + spend_control: None, plan_type: Some(AccountPlanType::Pro), }, ), diff --git a/codex-rs/app-server/tests/suite/v2/thread_add_credits_nudge_email.rs b/codex-rs/app-server/tests/suite/v2/thread_add_credits_nudge_email.rs new file mode 100644 index 000000000..14da550e0 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_add_credits_nudge_email.rs @@ -0,0 +1,96 @@ +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::(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::(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 +"# + ), + ) +} diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 6049c1697..04c06f334 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,8 +1,10 @@ +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; @@ -11,6 +13,7 @@ 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; @@ -20,6 +23,10 @@ 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 { @@ -259,12 +266,55 @@ 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()); + 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: 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> { + 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, @@ -397,20 +447,23 @@ impl Client { payload: RateLimitStatusPayload, ) -> Vec { 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.flatten().map(|details| *details), - payload.credits.flatten().map(|details| *details), + payload.rate_limit.map(|details| *details), + payload.credits.map(|details| *details), + spend_control, plan_type, )]; - if let Some(additional) = payload.additional_rate_limits.flatten() { + if let Some(additional) = payload.additional_rate_limits { 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, ) })); @@ -423,6 +476,7 @@ impl Client { limit_name: Option, rate_limit: Option, credits: Option, + spend_control: Option, plan_type: Option, ) -> RateLimitSnapshot { let (primary, secondary) = match rate_limit { @@ -438,6 +492,7 @@ impl Client { primary, secondary, credits: Self::map_credits(credits), + spend_control: Self::map_spend_control(spend_control), plan_type, } } @@ -467,6 +522,15 @@ impl Client { }) } + fn map_spend_control( + spend_control: Option, + ) -> Option { + 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, @@ -503,7 +567,6 @@ impl Client { #[cfg(test)] mod tests { use super::*; - use codex_backend_openapi_models::models::AdditionalRateLimitDetails; use pretty_assertions::assert_eq; #[test] @@ -522,7 +585,7 @@ mod tests { fn usage_payload_maps_primary_and_additional_rate_limits() { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::Pro, - rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { + rate_limit: Some(Box::new(crate::types::RateLimitStatusDetails { primary_window: Some(Some(Box::new(crate::types::RateLimitWindowSnapshot { used_percent: 42, limit_window_seconds: 300, @@ -536,8 +599,8 @@ mod tests { reset_at: 456, }))), ..Default::default() - }))), - additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { + })), + additional_rate_limits: Some(vec![crate::types::AdditionalRateLimitDetails { limit_name: "codex_other".to_string(), metered_feature: "codex_other".to_string(), rate_limit: Some(Some(Box::new(crate::types::RateLimitStatusDetails { @@ -550,13 +613,16 @@ mod tests { secondary_window: None, ..Default::default() }))), - }])), - credits: Some(Some(Box::new(crate::types::CreditStatusDetails { + }]), + credits: 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); @@ -580,6 +646,10 @@ 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")); @@ -589,6 +659,7 @@ 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)); } @@ -597,12 +668,13 @@ mod tests { let payload = RateLimitStatusPayload { plan_type: crate::types::PlanType::Plus, rate_limit: None, - additional_rate_limits: Some(Some(vec![AdditionalRateLimitDetails { + additional_rate_limits: Some(vec![crate::types::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); @@ -610,6 +682,7 @@ 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")); } @@ -627,6 +700,7 @@ mod tests { }), secondary: None, credits: None, + spend_control: None, plan_type: Some(AccountPlanType::Pro), }, RateLimitSnapshot { @@ -639,6 +713,7 @@ mod tests { }), secondary: None, credits: None, + spend_control: None, plan_type: Some(AccountPlanType::Pro), }, ]; @@ -650,4 +725,46 @@ 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" + ); + } } diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 397c2a2cd..44f5c36ff 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -3,9 +3,11 @@ 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; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 22e0984cd..2e7d368bd 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,17 +1,115 @@ +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 { + 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, + #[serde(default)] + pub account_ordering: Vec, +} + +impl AccountsCheckV4Response { + pub fn current_workspace_role( + &self, + current_account_id: Option<&str>, + ) -> Option { + 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, +} + +#[derive(Clone, Debug, Default, Deserialize)] +pub struct AccountsCheckV4Account { + #[serde(default, rename = "account_user_role")] + pub account_user_role: Option, +} + +#[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>, + #[serde(rename = "credits", default, skip_serializing_if = "Option::is_none")] + pub credits: Option>, + #[serde( + rename = "additional_rate_limits", + default, + skip_serializing_if = "Option::is_none" + )] + pub additional_rate_limits: Option>, + #[serde( + rename = "spend_control", + default, + skip_serializing_if = "Option::is_none" + )] + pub spend_control: Option>, +} + +#[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. @@ -220,6 +318,83 @@ 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 diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 1c71dc750..80772a25a 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -92,6 +92,7 @@ pub fn parse_rate_limit_for_limit( primary, secondary, credits, + spend_control: None, plan_type: None, }) } @@ -155,6 +156,7 @@ pub fn parse_rate_limit_event(payload: &str) -> Option { primary, secondary, credits, + spend_control: None, plan_type: event.plan_type, }) } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 55ce13afd..9631d6c7e 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -26,6 +26,7 @@ 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 } diff --git a/codex-rs/core/src/account.rs b/codex-rs/core/src/account.rs new file mode 100644 index 000000000..5870c0e2a --- /dev/null +++ b/codex-rs/core/src/account.rs @@ -0,0 +1,20 @@ +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 { + 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 +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9c71df3af..1d38280fa 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4784,6 +4784,10 @@ async fn submission_loop(sess: Arc, config: Arc, 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 @@ -4880,6 +4884,8 @@ 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; @@ -4924,6 +4930,48 @@ mod handlers { sess.close_unified_exec_processes().await; } + pub async fn send_add_credits_nudge_email( + sess: &Arc, + 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, @@ -7202,6 +7250,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) | EventMsg::ListSkillsResponse(_) + | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 44f5a2b7e..fa6e608f4 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2008,6 +2008,7 @@ 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()); @@ -2026,6 +2027,7 @@ 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()); @@ -2038,6 +2040,7 @@ 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, }) ); @@ -2114,6 +2117,7 @@ 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()); @@ -2128,6 +2132,7 @@ 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()); @@ -2140,6 +2145,7 @@ 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, }) ); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 5e62199d2..2e41f7cf6 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -5,6 +5,7 @@ // the TUI or the tracing stack). #![deny(clippy::print_stdout, clippy::print_stderr)] +mod account; mod apply_patch; mod apps; mod arc_monitor; @@ -112,7 +113,13 @@ 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; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 206f75060..ce01994e4 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -229,6 +229,9 @@ 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); } diff --git a/codex-rs/core/src/state/session_tests.rs b/codex-rs/core/src/state/session_tests.rs index 1af7ccc8f..c9b9e68f1 100644 --- a/codex-rs/core/src/state/session_tests.rs +++ b/codex-rs/core/src/state/session_tests.rs @@ -48,6 +48,7 @@ async fn set_rate_limits_defaults_limit_id_to_codex_when_missing() { }), secondary: None, credits: None, + spend_control: None, plan_type: None, }); @@ -75,6 +76,7 @@ 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 { @@ -87,6 +89,7 @@ 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, }); @@ -118,6 +121,7 @@ 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), }); @@ -131,6 +135,7 @@ 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, }); @@ -150,6 +155,7 @@ 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), }) ); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index a2a110b2d..1bbf18499 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -2374,6 +2374,7 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, + "spend_control": null, "plan_type": null } }) @@ -2425,6 +2426,7 @@ async fn token_count_includes_rate_limits_snapshot() { "resets_at": 1704074400 }, "credits": null, + "spend_control": null, "plan_type": null } }) @@ -2499,6 +2501,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "resets_at": null }, "credits": null, + "spend_control": null, "plan_type": null }); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 424f6764e..8407191ce 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1033,6 +1033,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { "resets_at": null }, "credits": null, + "spend_control": null, "plan_type": null } }) diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 27064b683..88f40ed72 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -128,6 +128,7 @@ 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(), diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 71857c970..5f2d5ab88 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -302,6 +302,14 @@ 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 { + 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 diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs index f70696d5a..fd502ccec 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/login/src/token_data.rs @@ -36,6 +36,8 @@ pub struct IdTokenInfo { pub chatgpt_user_id: Option, /// Organization/workspace identifier associated with the token, if present. pub chatgpt_account_id: Option, + /// Whether the current user owns the workspace, when present in the token. + pub is_org_owner: Option, pub raw_jwt: String, } @@ -88,6 +90,8 @@ struct AuthClaims { user_id: Option, #[serde(default)] chatgpt_account_id: Option, + #[serde(default)] + is_org_owner: Option, } #[derive(Deserialize)] @@ -139,6 +143,7 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result Ok(IdTokenInfo { email, @@ -146,6 +151,7 @@ pub fn parse_chatgpt_jwt_claims(jwt: &str) -> Result RateLimitSnapshot { resets_at: Some(secondary_reset_at), }), credits: None, + spend_control: None, plan_type: None, } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f1bbc0a7f..dddd3236e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -635,6 +635,9 @@ 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, @@ -745,6 +748,7 @@ 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", @@ -1503,6 +1507,9 @@ 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), @@ -1556,6 +1563,18 @@ 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, +} + #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum HookEventName { @@ -2122,6 +2141,7 @@ pub struct RateLimitSnapshot { pub primary: Option, pub secondary: Option, pub credits: Option, + pub spend_control: Option, pub plan_type: Option, } @@ -2144,6 +2164,11 @@ pub struct CreditsSnapshot { pub balance: Option, } +#[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; diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 4b50781e7..34c2612ad 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -164,6 +164,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) | EventMsg::ListSkillsResponse(_) + | EventMsg::AddCreditsNudgeEmailResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete | EventMsg::DeprecationNotice(_) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 76da2ba90..c7c9e79c7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -537,6 +537,9 @@ impl ThreadEventStore { ThreadBufferedEvent::Request(_) | ThreadBufferedEvent::Notification(ServerNotification::HookStarted(_)) | ThreadBufferedEvent::Notification(ServerNotification::HookCompleted(_)) + | ThreadBufferedEvent::Notification( + ServerNotification::AddCreditsNudgeEmailCompleted(_), + ) | ThreadBufferedEvent::FeedbackSubmission(_) ) } @@ -1096,6 +1099,8 @@ 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, @@ -2426,6 +2431,10 @@ 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) @@ -3649,6 +3658,8 @@ 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(), @@ -3698,6 +3709,8 @@ 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, @@ -3732,6 +3745,8 @@ 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, @@ -3771,6 +3786,8 @@ 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, @@ -4549,6 +4566,23 @@ 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); } @@ -6329,6 +6363,8 @@ 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; @@ -6716,6 +6752,8 @@ 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, @@ -9619,6 +9657,29 @@ 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(); @@ -10680,6 +10741,8 @@ 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, diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 0bd78f353..5df4c18e5 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -176,6 +176,8 @@ impl App { notification.auth_mode, notification.plan_type, ), + notification.workspace_role, + notification.is_workspace_owner, notification.plan_type, matches!( notification.auth_mode, @@ -400,6 +402,9 @@ 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(_) @@ -1027,10 +1032,14 @@ 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; @@ -1061,6 +1070,19 @@ 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(); diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0646cc297..14e3c2dca 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -98,6 +98,7 @@ pub(crate) enum AppCommandView<'a> { SetThreadName { name: &'a str, }, + SendAddCreditsNudgeEmail, Shutdown, ThreadRollback { num_turns: u32, @@ -391,6 +392,7 @@ impl AppCommand { num_turns: *num_turns, }, Op::Review { review_request } => AppCommandView::Review { review_request }, + Op::SendAddCreditsNudgeEmail => AppCommandView::SendAddCreditsNudgeEmail, op => AppCommandView::Other(op), } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 78a252535..bab9af27f 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -170,6 +170,10 @@ pub(crate) enum AppEvent { result: Result, String>, }, + /// Ask Codex to notify the current workspace owner that credits need to be + /// added. + NotifyWorkspaceOwner, + /// Result of prefetching connectors. ConnectorsLoaded { result: Result, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 52d639517..3bfbc8404 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -24,6 +24,8 @@ 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; @@ -102,6 +104,8 @@ pub(crate) struct AppServerBootstrap { pub(crate) account_email: Option, pub(crate) auth_mode: Option, pub(crate) status_account_display: Option, + pub(crate) workspace_role: Option, + pub(crate) is_workspace_owner: Option, pub(crate) plan_type: Option, /// Whether the configured model provider needs OpenAI-style auth. Combined /// with `has_chatgpt_account` to decide if a startup rate-limit prefetch @@ -216,6 +220,8 @@ impl AppServerSession { account_email, auth_mode, status_account_display, + workspace_role, + is_workspace_owner, plan_type, feedback_audience, has_chatgpt_account, @@ -225,6 +231,8 @@ impl AppServerSession { Some(TelemetryAuthMode::ApiKey), Some(StatusAccountDisplay::ApiKey), None, + None, + None, FeedbackAudience::External, false, ), @@ -241,17 +249,30 @@ 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, FeedbackAudience::External, false), + None => ( + None, + 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, @@ -549,6 +570,24 @@ 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, @@ -1121,6 +1160,9 @@ 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, } } @@ -1145,6 +1187,14 @@ 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::*; diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index e3eecf2ed..efaa124d0 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -648,7 +648,23 @@ impl BottomPaneView for ListSelectionView { && !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { - if let Some(idx) = c + 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 .to_digit(10) .map(|d| d as usize) .and_then(|d| d.checked_sub(1)) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3575799b2..74b203289 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -67,6 +67,7 @@ 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; @@ -91,6 +92,7 @@ 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; @@ -133,6 +135,7 @@ 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)] @@ -191,6 +194,7 @@ 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; @@ -555,6 +559,8 @@ pub(crate) struct ChatWidgetInit { pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) is_first_run: bool, pub(crate) status_account_display: Option, + pub(crate) initial_workspace_role: Option, + pub(crate) initial_is_workspace_owner: Option, pub(crate) initial_plan_type: Option, pub(crate) model: Option, pub(crate) startup_tooltip_override: Option, @@ -601,6 +607,32 @@ 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 { match info { @@ -756,6 +788,8 @@ pub(crate) struct ChatWidget { /// The currently active collaboration mask, if any. active_collaboration_mask: Option, has_chatgpt_account: bool, + workspace_role: Option, + is_workspace_owner: Option, model_catalog: Arc, session_telemetry: SessionTelemetry, session_header: SessionHeader, @@ -766,6 +800,8 @@ pub(crate) struct ChatWidget { refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>, next_status_refresh_request_id: u64, plan_type: Option, + notify_workspace_owner_in_flight: bool, + pending_workspace_owner_notification_prompt: bool, rate_limit_warnings: RateLimitWarningState, rate_limit_switch_prompt: RateLimitSwitchPromptState, adaptive_chunking: AdaptiveChunkingPolicy, @@ -2677,6 +2713,15 @@ 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); @@ -2745,6 +2790,7 @@ 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. @@ -2787,15 +2833,34 @@ 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) { self.submit_pending_steers_after_interrupt = false; self.finalize_turn(); - self.add_to_history(history_cell::new_error_event(message)); + self.add_to_history(history_cell::new_error_event_with_hint(message, hint)); 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, @@ -2812,7 +2877,7 @@ impl ChatWidget { match info { RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message), RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_error(message) + self.on_rate_limit_error(message); } } } else { @@ -4612,6 +4677,8 @@ impl ChatWidget { feedback, is_first_run, status_account_display, + initial_workspace_role, + initial_is_workspace_owner, initial_plan_type, model, startup_tooltip_override, @@ -4673,6 +4740,8 @@ 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), @@ -4683,6 +4752,8 @@ 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(), @@ -5322,9 +5393,7 @@ impl ChatWidget { } SlashCommand::Status => { if self.should_prefetch_rate_limits() { - let request_id = self.next_status_refresh_request_id; - self.next_status_refresh_request_id = - self.next_status_refresh_request_id.wrapping_add(1); + let request_id = self.next_rate_limit_refresh_request_id(); self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id)); self.app_event_tx.send(AppEvent::RefreshRateLimits { origin: RateLimitRefreshOrigin::StatusCommand { request_id }, @@ -6583,6 +6652,18 @@ 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); @@ -6991,7 +7072,7 @@ impl ChatWidget { self.on_server_overloaded_error(message) } RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => { - self.on_error(message) + self.on_rate_limit_error(message) } } } else { @@ -7047,6 +7128,9 @@ 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(), @@ -9586,19 +9670,268 @@ impl ChatWidget { self.plan_type } + pub(crate) fn current_is_workspace_owner(&self) -> Option { + self.workspace_role + .map(|role| { + matches!( + role, + AppServerWorkspaceRole::AccountOwner | AppServerWorkspaceRole::AccountAdmin + ) + }) + .or(self.is_workspace_owner) + } + + pub(crate) fn current_workspace_role(&self) -> Option { + self.workspace_role + } + pub(crate) fn has_chatgpt_account(&self) -> bool { self.has_chatgpt_account } + fn usage_based_workspace_rate_limit_state(&self) -> Option { + 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 { + 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)> { + 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, + ) { + 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, + workspace_role: Option, + is_workspace_owner: Option, plan_type: Option, 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()); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__live_app_server_usage_limit_error_shows_notify_owner_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__live_app_server_usage_limit_error_shows_notify_owner_hint.snap new file mode 100644 index 000000000..4ce3f0197 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__live_app_server_usage_limit_error_shows_notify_owner_hint.snap @@ -0,0 +1,6 @@ +--- +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] diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3ab6e1f53..7fbcb90b9 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -127,6 +127,7 @@ 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; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 09bc9a701..085e4976d 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -569,6 +569,117 @@ 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; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 0a7deee58..f5241fac5 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -104,6 +104,7 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { }), secondary: None, credits: None, + spend_control: None, plan_type: None, } } @@ -189,6 +190,8 @@ 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()), @@ -199,6 +202,8 @@ 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(), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 660bb8fa9..af0c8b90f 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1245,6 +1245,8 @@ 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, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index f551f159f..be421eda4 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -71,6 +71,8 @@ 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, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index b04ce74a1..50de7064c 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -130,6 +130,8 @@ 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, @@ -234,6 +236,7 @@ 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 @@ -253,6 +256,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { }), secondary: None, credits: None, + spend_control: None, plan_type: None, })); @@ -291,6 +295,7 @@ 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)); @@ -309,6 +314,7 @@ 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)); @@ -327,6 +333,7 @@ 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)); @@ -350,6 +357,7 @@ 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), })); @@ -363,6 +371,7 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() { }), secondary: None, credits: None, + spend_control: None, plan_type: Some(PlanType::Pro), })); @@ -387,6 +396,101 @@ 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; @@ -415,6 +519,7 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() { }), secondary: None, credits: None, + spend_control: None, plan_type: None, })); diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index ba14ceb03..388f9fa64 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -120,3 +120,212 @@ 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::>(); + 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::>(); + 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::>(); + 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" + ); +} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 67c7e9f98..987005db0 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2160,10 +2160,17 @@ pub(crate) fn new_info_event(message: String, hint: Option) -> 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) -> 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 lines: Vec> = vec![vec![format!("■ {message}").red()].into()]; + let mut lines: Vec> = vec![vec![format!("■ {message}").red()].into()]; + if let Some(hint) = hint { + lines.push(vec![" ".into(), hint.dark_gray()].into()); + } PlainHistoryCell { lines } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 700d685b0..76609d2c6 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -891,7 +891,9 @@ 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") + EnvFilter::new( + "codex_core=info,codex_tui=info,codex_rmcp_client=info,codex_app_server=info", + ) }) }; diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 559da21e8..89bd103e0 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -16,6 +16,7 @@ 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 = "█"; @@ -98,6 +99,8 @@ pub(crate) struct RateLimitSnapshotDisplay { pub secondary: Option, /// Optional credits metadata when available. pub credits: Option, + /// Optional spend-control metadata for usage-based workspace plans. + pub spend_control: Option, } /// Display-ready credits state extracted from protocol snapshots. @@ -111,6 +114,13 @@ pub(crate) struct CreditsSnapshotDisplay { pub balance: Option, } +/// 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 @@ -140,6 +150,10 @@ 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), } } @@ -153,6 +167,14 @@ 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 @@ -175,7 +197,7 @@ pub(crate) fn compose_rate_limit_data_many( return StatusRateLimitData::Missing; } - let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3)); + let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(4)); let mut stale = false; for snapshot in snapshots { @@ -263,6 +285,12 @@ 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) { @@ -322,6 +350,15 @@ fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option Option { + spend_control.reached.then(|| StatusRateLimitRow { + label: "Spend cap".to_string(), + value: StatusRateLimitValue::Text("Reached".to_string()), + }) +} + fn format_credit_balance(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { @@ -349,6 +386,7 @@ 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; @@ -375,6 +413,7 @@ mod tests { unlimited: false, balance: Some("25".to_string()), }), + spend_control: None, }; let other = RateLimitSnapshotDisplay { limit_name: "codex-other".to_string(), @@ -386,6 +425,7 @@ mod tests { unlimited: false, balance: Some("99".to_string()), }), + spend_control: None, }; let rows = match compose_rate_limit_data_many(&[codex, other], now) { @@ -423,6 +463,7 @@ mod tests { window_minutes: None, }), credits: None, + spend_control: None, }; let rows = match compose_rate_limit_data_many(&[other], now) { @@ -439,4 +480,28 @@ 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 = rows.iter().map(|row| row.label.clone()).collect(); + assert_eq!(labels, vec!["Spend cap".to_string()]); + } } diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_spend_cap_reached_message.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_spend_cap_reached_message.snap new file mode 100644 index 000000000..e1399c9a6 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_spend_cap_reached_message.snap @@ -0,0 +1,22 @@ +--- +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: │ +│ │ +│ Token usage: 750 total (500 input + 250 output) │ +│ Context window: 100% left (750 used / 272K) │ +│ Spend cap: Reached │ +╰───────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index cf2a2d895..fceeeaa25 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -136,6 +136,7 @@ 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); @@ -319,6 +320,7 @@ 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); @@ -370,6 +372,7 @@ 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); @@ -419,6 +422,7 @@ 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); @@ -468,6 +472,7 @@ 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); @@ -515,6 +520,7 @@ 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); @@ -620,6 +626,7 @@ 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); @@ -733,6 +740,7 @@ 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); @@ -803,6 +811,7 @@ 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); @@ -856,6 +865,69 @@ 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 @@ -912,6 +984,7 @@ 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 @@ -982,6 +1055,7 @@ 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); @@ -1052,6 +1126,7 @@ 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);