From 6b39d0c657924403b77040186012e911344342c0 Mon Sep 17 00:00:00 2001 From: richardopenai Date: Fri, 17 Apr 2026 21:41:57 -0700 Subject: [PATCH] [codex] Add owner nudge app-server API (#18220) ## Summary Second PR in the split from #17956. Stacked on #18227. - adds app-server v2 protocol/schema support for `account/sendAddCreditsNudgeEmail` - adds the backend-client `send_add_credits_nudge_email` request and request body mapping - handles the app-server request with auth checks, backend call, and cooldown mapping - adds the disabled `workspace_owner_usage_nudge` feature flag and focused app-server/backend tests ## Validation - `cargo test -p codex-backend-client` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server rate_limits` - `cargo test -p codex-tui workspace_` - `cargo test -p codex-tui status_` - `just fmt` - `just fix -p codex-backend-client` - `just fix -p codex-app-server-protocol` - `just fix -p codex-app-server` - `just fix -p codex-tui` --- .../schema/json/ClientRequest.json | 42 ++++ .../codex_app_server_protocol.schemas.json | 64 ++++++ .../codex_app_server_protocol.v2.schemas.json | 64 ++++++ .../v2/SendAddCreditsNudgeEmailParams.json | 22 ++ .../v2/SendAddCreditsNudgeEmailResponse.json | 22 ++ .../schema/typescript/ClientRequest.ts | 3 +- .../v2/AddCreditsNudgeCreditType.ts | 5 + .../v2/AddCreditsNudgeEmailStatus.ts | 5 + .../v2/SendAddCreditsNudgeEmailParams.ts | 6 + .../v2/SendAddCreditsNudgeEmailResponse.ts | 6 + .../schema/typescript/v2/index.ts | 4 + .../src/protocol/common.rs | 5 + .../app-server-protocol/src/protocol/v2.rs | 30 +++ codex-rs/app-server/README.md | 13 +- .../app-server/src/codex_message_processor.rs | 77 +++++++ .../app-server/tests/common/mcp_process.rs | 11 + .../app-server/tests/suite/v2/rate_limits.rs | 208 ++++++++++++++++++ codex-rs/backend-client/src/client.rs | 89 ++++++++ codex-rs/backend-client/src/lib.rs | 1 + 19 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d195d57b6..3c6a8c467 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -5,6 +5,13 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.", "enum": [ @@ -2548,6 +2555,17 @@ } ] }, + "SendAddCreditsNudgeEmailParams": { + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "type": "object" + }, "ServiceTier": { "enum": [ "fast", @@ -4982,6 +5000,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "type": "object" + }, { "properties": { "id": { 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 d9f79d920..6ce0d2190 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 @@ -1440,6 +1440,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "type": "object" + }, { "properties": { "id": { @@ -5103,6 +5127,20 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12267,6 +12305,32 @@ }, "type": "object" }, + "SendAddCreditsNudgeEmailParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "creditType": { + "$ref": "#/definitions/v2/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" + }, + "SendAddCreditsNudgeEmailResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/v2/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "type": "object" + }, "ServerRequestResolvedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 621903386..74cde880e 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 @@ -114,6 +114,20 @@ "title": "AccountUpdatedNotification", "type": "object" }, + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + }, + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + }, "AgentMessageDeltaNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -2022,6 +2036,30 @@ "title": "Account/rateLimits/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/sendAddCreditsNudgeEmail" + ], + "title": "Account/sendAddCreditsNudgeEmailRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SendAddCreditsNudgeEmailParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/sendAddCreditsNudgeEmailRequest", + "type": "object" + }, { "properties": { "id": { @@ -8999,6 +9037,32 @@ }, "type": "object" }, + "SendAddCreditsNudgeEmailParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" + }, + "SendAddCreditsNudgeEmailResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "type": "object" + }, "ServerNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Notification sent from the server to the client.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json new file mode 100644 index 000000000..c3c63edef --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailParams.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AddCreditsNudgeCreditType": { + "enum": [ + "credits", + "usage_limit" + ], + "type": "string" + } + }, + "properties": { + "creditType": { + "$ref": "#/definitions/AddCreditsNudgeCreditType" + } + }, + "required": [ + "creditType" + ], + "title": "SendAddCreditsNudgeEmailParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json new file mode 100644 index 000000000..bfeba322b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SendAddCreditsNudgeEmailResponse.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AddCreditsNudgeEmailStatus": { + "enum": [ + "sent", + "cooldown_active" + ], + "type": "string" + } + }, + "properties": { + "status": { + "$ref": "#/definitions/AddCreditsNudgeEmailStatus" + } + }, + "required": [ + "status" + ], + "title": "SendAddCreditsNudgeEmailResponse", + "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 d21f95145..b9ef6401b 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 { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; +import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; @@ -69,4 +70,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": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "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": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "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/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": "thread/turns/list", id: RequestId, params: ThreadTurnsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "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": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "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": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "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/v2/AddCreditsNudgeCreditType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.ts new file mode 100644 index 000000000..70498d6a6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeCreditType.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 AddCreditsNudgeCreditType = "credits" | "usage_limit"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.ts new file mode 100644 index 000000000..2b62da68e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AddCreditsNudgeEmailStatus.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 AddCreditsNudgeEmailStatus = "sent" | "cooldown_active"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.ts new file mode 100644 index 000000000..383ad4aab --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailParams.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 { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; + +export type SendAddCreditsNudgeEmailParams = { creditType: AddCreditsNudgeCreditType, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.ts new file mode 100644 index 000000000..71dcb190a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SendAddCreditsNudgeEmailResponse.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 { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; + +export type SendAddCreditsNudgeEmailResponse = { status: AddCreditsNudgeEmailStatus, }; 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 6ae7ebb7d..cbdef1210 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 { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; +export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; @@ -259,6 +261,8 @@ export type { ReviewTarget } from "./ReviewTarget"; export type { SandboxMode } from "./SandboxMode"; export type { SandboxPolicy } from "./SandboxPolicy"; export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; +export type { SendAddCreditsNudgeEmailParams } from "./SendAddCreditsNudgeEmailParams"; +export type { SendAddCreditsNudgeEmailResponse } from "./SendAddCreditsNudgeEmailResponse"; export type { ServerRequestResolvedNotification } from "./ServerRequestResolvedNotification"; export type { SessionSource } from "./SessionSource"; export type { SkillDependencies } from "./SkillDependencies"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f4461050e..4e5882f83 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -524,6 +524,11 @@ client_request_definitions! { response: v2::GetAccountRateLimitsResponse, }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { + params: v2::SendAddCreditsNudgeEmailParams, + response: v2::SendAddCreditsNudgeEmailResponse, + }, + FeedbackUpload => "feedback/upload" { params: v2::FeedbackUploadParams, response: v2::FeedbackUploadResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 6390b8f10..508b90b34 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1789,6 +1789,36 @@ pub struct GetAccountRateLimitsResponse { pub rate_limits_by_limit_id: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailParams { + pub credit_type: AddCreditsNudgeCreditType, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailResponse { + pub status: AddCreditsNudgeEmailStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeEmailStatus { + Sent, + CooldownActive, +} + #[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 2ade95b6a..e34e44d8c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1448,6 +1448,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available. - `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. +- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. - `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`. @@ -1540,7 +1541,7 @@ Field notes: ```json { "method": "account/rateLimits/read", "id": 7 } -{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } } +{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } } { "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } ``` @@ -1549,6 +1550,16 @@ Field notes: - `usedPercent` is current usage within the OpenAI quota window. - `windowDurationMins` is the quota window length. - `resetsAt` is a Unix timestamp (seconds) for the next reset. +- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached. + +### 8) Notify a workspace owner about a limit + +```json +{ "method": "account/sendAddCreditsNudgeEmail", "id": 8, "params": { "creditType": "credits" } } +{ "id": 8, "result": { "status": "sent" } } +``` + +Use `creditType: "credits"` when workspace credits are depleted, or `creditType: "usage_limit"` when the workspace usage limit has been reached. If the owner was already notified recently, the response status is `cooldown_active`. ## Experimental API Opt-in diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d2ac241ed..69cbd6cf4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -29,6 +29,8 @@ use codex_analytics::TurnSteerRequestError; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; @@ -117,6 +119,8 @@ use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::SkillSummary; @@ -203,6 +207,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_arg0::Arg0DispatchPaths; +use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; use codex_chatgpt::connectors; use codex_cloud_requirements::cloud_requirements_loader; @@ -1187,6 +1192,10 @@ impl CodexMessageProcessor { self.get_account_rate_limits(to_connection_request_id(request_id)) .await; } + ClientRequest::SendAddCreditsNudgeEmail { request_id, params } => { + self.send_add_credits_nudge_email(to_connection_request_id(request_id), params) + .await; + } ClientRequest::FeedbackUpload { request_id, params } => { self.upload_feedback(to_connection_request_id(request_id), params) .await; @@ -1901,6 +1910,74 @@ impl CodexMessageProcessor { } } + async fn send_add_credits_nudge_email( + &self, + request_id: ConnectionRequestId, + params: SendAddCreditsNudgeEmailParams, + ) { + match self.send_add_credits_nudge_email_inner(params).await { + Ok(status) => { + self.outgoing + .send_response(request_id, SendAddCreditsNudgeEmailResponse { status }) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn send_add_credits_nudge_email_inner( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "codex account authentication required to notify workspace owner" + .to_string(), + data: None, + }); + }; + + if !auth.is_chatgpt_auth() { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "chatgpt authentication required to notify workspace owner".to_string(), + data: None, + }); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to construct backend client: {err}"), + data: None, + })?; + + match client + .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) + .await + { + Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), + Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { + Ok(AddCreditsNudgeEmailStatus::CooldownActive) + } + Err(err) => Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to notify workspace owner: {err}"), + data: None, + }), + } + } + + fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType { + match value { + AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits, + AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit, + } + } + async fn fetch_account_rate_limits( &self, ) -> Result< diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 27bacbcd2..05bb66d57 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::PluginReadParams; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::ThreadArchiveParams; @@ -305,6 +306,16 @@ impl McpProcess { .await } + /// Send an `account/sendAddCreditsNudgeEmail` JSON-RPC request. + pub async fn send_add_credits_nudge_email_request( + &mut self, + params: SendAddCreditsNudgeEmailParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/sendAddCreditsNudgeEmail", params) + .await + } + /// Send an `account/read` JSON-RPC request. pub async fn send_get_account_request( &mut self, 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 20b1729f1..b15960c1e 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -3,6 +3,8 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; @@ -11,6 +13,8 @@ use codex_app_server_protocol::RateLimitReachedType; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; @@ -27,6 +31,7 @@ use wiremock::matchers::path; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const INTERNAL_ERROR_CODE: i64 = -32603; #[tokio::test] async fn get_account_rate_limits_requires_auth() -> Result<()> { @@ -229,6 +234,209 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { Ok(()) } +#[tokio::test] +async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + 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_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "codex account authentication required to notify workspace owner" + ); + + Ok(()) +} + +#[tokio::test] +async fn send_add_credits_nudge_email_requires_chatgpt_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let request_id = mcp + .send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "chatgpt authentication required to notify workspace owner" + ); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(wiremock::matchers::body_json(json!({ + "credit_type": "usage_limit", + }))) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + 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_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: SendAddCreditsNudgeEmailResponse = to_response(response)?; + + assert_eq!(received.status, AddCreditsNudgeEmailStatus::Sent); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn send_add_credits_nudge_email_maps_cooldown() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .respond_with(ResponseTemplate::new(429)) + .mount(&server) + .await; + + 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_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: SendAddCreditsNudgeEmailResponse = to_response(response)?; + + assert_eq!(received.status, AddCreditsNudgeEmailStatus::CooldownActive); + + Ok(()) +} + +#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")] +#[tokio::test] +async fn send_add_credits_nudge_email_surfaces_backend_failure() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + Mock::given(method("POST")) + .and(path("/api/codex/accounts/send_add_credits_nudge_email")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + + 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_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INTERNAL_ERROR_CODE); + assert!( + error + .error + .message + .contains("failed to notify workspace owner"), + "unexpected error message: {}", + error.error.message + ); + assert_eq!(error.error.data, None); + + Ok(()) +} + async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { let request_id = mcp.send_login_account_api_key_request(api_key).await?; let response: JSONRPCResponse = timeout( diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 84fbffb69..8f84ef28f 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -20,6 +20,7 @@ use reqwest::header::HeaderMap; use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use reqwest::header::USER_AGENT; +use serde::Serialize; use serde::de::DeserializeOwned; use std::fmt; @@ -81,6 +82,18 @@ impl From for RequestError { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Serialize)] +struct SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PathStyle { /// /api/codex/… @@ -282,6 +295,21 @@ impl Client { Ok(Self::rate_limit_snapshots_from_payload(payload)) } + pub async fn send_add_credits_nudge_email( + &self, + credit_type: AddCreditsNudgeCreditType, + ) -> std::result::Result<(), RequestError> { + let url = self.send_add_credits_nudge_email_url(); + let req = self + .http + .post(&url) + .headers(self.headers()) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .json(&SendAddCreditsNudgeEmailRequest { credit_type }); + self.exec_request_detailed(req, "POST", &url).await?; + Ok(()) + } + pub async fn list_tasks( &self, limit: Option, @@ -490,6 +518,21 @@ impl Client { } } + fn send_add_credits_nudge_email_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!( + "{}/api/codex/accounts/send_add_credits_nudge_email", + self.base_url + ), + PathStyle::ChatGptApi => { + format!( + "{}/wham/accounts/send_add_credits_nudge_email", + self.base_url + ) + } + } + } + fn map_rate_limit_window( window: Option>>, ) -> Option { @@ -767,4 +810,50 @@ mod tests { let snapshots = Client::rate_limit_snapshots_from_payload(payload); assert_eq!(snapshots[0].rate_limit_reached_type, None); } + + #[test] + fn add_credits_nudge_email_uses_expected_paths_and_bodies() { + let codex_client = Client { + base_url: "https://example.test".to_string(), + http: reqwest::Client::new(), + bearer_token: None, + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: PathStyle::CodexApi, + }; + assert_eq!( + codex_client.send_add_credits_nudge_email_url(), + "https://example.test/api/codex/accounts/send_add_credits_nudge_email" + ); + + let chatgpt_client = Client { + base_url: "https://chatgpt.com/backend-api".to_string(), + http: reqwest::Client::new(), + bearer_token: None, + user_agent: None, + chatgpt_account_id: None, + chatgpt_account_is_fedramp: false, + path_style: PathStyle::ChatGptApi, + }; + assert_eq!( + chatgpt_client.send_add_credits_nudge_email_url(), + "https://chatgpt.com/backend-api/wham/accounts/send_add_credits_nudge_email" + ); + + assert_eq!( + serde_json::to_value(SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType::Credits, + }) + .unwrap(), + serde_json::json!({ "credit_type": "credits" }) + ); + assert_eq!( + serde_json::to_value(SendAddCreditsNudgeEmailRequest { + credit_type: AddCreditsNudgeCreditType::UsageLimit, + }) + .unwrap(), + serde_json::json!({ "credit_type": "usage_limit" }) + ); + } } diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 397c2a2cd..300da8156 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -1,6 +1,7 @@ mod client; pub(crate) mod types; +pub use client::AddCreditsNudgeCreditType; pub use client::Client; pub use client::RequestError; pub use types::CodeTaskDetailsResponse;