diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index cea7a3ee81..bd75f7b403 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1524,6 +1524,20 @@ }, "type": "object" }, + "GetAccountRateLimitsParams": { + "description": "Usage-read capabilities of the requesting client, never inferred from its experiment arm.", + "properties": { + "excludeResetCreditDetails": { + "description": "Skip the separate reset-credit detail lookup for background usage polls. The usage response still includes the available count; omitted/false preserves detailed reads.", + "type": "boolean" + }, + "supportsLunaReserve": { + "description": "The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, allow the backend to record experiment exposure after ordinary usage is blocked.", + "type": "boolean" + } + }, + "type": "object" + }, "GetAccountTokenUsageParams": { "properties": { "threadId": { @@ -7650,7 +7664,14 @@ "type": "string" }, "params": { - "type": "null" + "anyOf": [ + { + "$ref": "#/definitions/GetAccountRateLimitsParams" + }, + { + "type": "null" + } + ] } }, "required": [ 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 8d8300b7e5..b6f11c36b5 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 @@ -2111,7 +2111,14 @@ "type": "string" }, "params": { - "type": "null" + "anyOf": [ + { + "$ref": "#/definitions/v2/GetAccountRateLimitsParams" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -12258,6 +12265,20 @@ "title": "GetAccountParams", "type": "object" }, + "GetAccountRateLimitsParams": { + "description": "Usage-read capabilities of the requesting client, never inferred from its experiment arm.", + "properties": { + "excludeResetCreditDetails": { + "description": "Skip the separate reset-credit detail lookup for background usage polls. The usage response still includes the available count; omitted/false preserves detailed reads.", + "type": "boolean" + }, + "supportsLunaReserve": { + "description": "The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, allow the backend to record experiment exposure after ordinary usage is blocked.", + "type": "boolean" + } + }, + "type": "object" + }, "GetAccountRateLimitsResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12268,6 +12289,13 @@ "null" ] }, + "ordinaryUsageAllowed": { + "description": "Backend permission for ordinary included usage, validated against the active account. Null means unavailable; clients must not infer recovery from percentages or reset times.", + "type": [ + "boolean", + "null" + ] + }, "rateLimitResetCredits": { "anyOf": [ { @@ -15497,6 +15525,18 @@ ], "type": "string" }, + "NullableGetAccountRateLimitsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "$ref": "#/definitions/v2/GetAccountRateLimitsParams" + }, + { + "type": "null" + } + ], + "title": "Nullable_GetAccountRateLimitsParams" + }, "NullableGetAccountTokenUsageParams": { "$schema": "http://json-schema.org/draft-07/schema#", "anyOf": [ 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 98e1b0bbf4..90df043826 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 @@ -3601,7 +3601,14 @@ "type": "string" }, "params": { - "type": "null" + "anyOf": [ + { + "$ref": "#/definitions/GetAccountRateLimitsParams" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -8269,6 +8276,20 @@ "title": "GetAccountParams", "type": "object" }, + "GetAccountRateLimitsParams": { + "description": "Usage-read capabilities of the requesting client, never inferred from its experiment arm.", + "properties": { + "excludeResetCreditDetails": { + "description": "Skip the separate reset-credit detail lookup for background usage polls. The usage response still includes the available count; omitted/false preserves detailed reads.", + "type": "boolean" + }, + "supportsLunaReserve": { + "description": "The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, allow the backend to record experiment exposure after ordinary usage is blocked.", + "type": "boolean" + } + }, + "type": "object" + }, "GetAccountRateLimitsResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8279,6 +8300,13 @@ "null" ] }, + "ordinaryUsageAllowed": { + "description": "Backend permission for ordinary included usage, validated against the active account. Null means unavailable; clients must not infer recovery from percentages or reset times.", + "type": [ + "boolean", + "null" + ] + }, "rateLimitResetCredits": { "anyOf": [ { @@ -11569,6 +11597,18 @@ ], "type": "string" }, + "NullableGetAccountRateLimitsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "$ref": "#/definitions/GetAccountRateLimitsParams" + }, + { + "type": "null" + } + ], + "title": "Nullable_GetAccountRateLimitsParams" + }, "NullableGetAccountTokenUsageParams": { "$schema": "http://json-schema.org/draft-07/schema#", "anyOf": [ 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 abb94b127e..1448eaf545 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -284,6 +284,13 @@ "null" ] }, + "ordinaryUsageAllowed": { + "description": "Backend permission for ordinary included usage, validated against the active account. Null means unavailable; clients must not infer recovery from percentages or reset times.", + "type": [ + "boolean", + "null" + ] + }, "rateLimitResetCredits": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/NullableGetAccountRateLimitsParams.json b/codex-rs/app-server-protocol/schema/json/v2/NullableGetAccountRateLimitsParams.json new file mode 100644 index 0000000000..a163934d64 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/NullableGetAccountRateLimitsParams.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "anyOf": [ + { + "$ref": "#/definitions/GetAccountRateLimitsParams" + }, + { + "type": "null" + } + ], + "definitions": { + "GetAccountRateLimitsParams": { + "description": "Usage-read capabilities of the requesting client, never inferred from its experiment arm.", + "properties": { + "excludeResetCreditDetails": { + "description": "Skip the separate reset-credit detail lookup for background usage polls. The usage response still includes the available count; omitted/false preserves detailed reads.", + "type": "boolean" + }, + "supportsLunaReserve": { + "description": "The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, allow the backend to record experiment exposure after ordinary usage is blocked.", + "type": "boolean" + } + }, + "type": "object" + } + }, + "title": "Nullable_GetAccountRateLimitsParams" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index af37b0fc56..3c223b2cc1 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 7457114b80..fc866601b4 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 6907bc38af..0ccca6ce2c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -35,6 +35,7 @@ import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; +import type { GetAccountRateLimitsParams } from "./v2/GetAccountRateLimitsParams"; import type { GetAccountTokenUsageParams } from "./v2/GetAccountTokenUsageParams"; import type { HooksListParams } from "./v2/HooksListParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; @@ -101,4 +102,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/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/section/move", id: RequestId, params: ThreadSectionMoveParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/revert", id: RequestId, params: ThreadRevertParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "threadSection/list", id: RequestId, params: ThreadSectionListParams, } | { "method": "threadSection/create", id: RequestId, params: ThreadSectionCreateParams, } | { "method": "threadSection/update", id: RequestId, params: ThreadSectionUpdateParams, } | { "method": "threadSection/delete", id: RequestId, params: ThreadSectionDeleteParams, } | { "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/items/list", id: RequestId, params: ThreadItemsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/reconcile", id: RequestId, params: PluginReconcileParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/read", id: RequestId, params: AppsReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "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": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "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/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params?: GetAccountTokenUsageParams | undefined, } | { "method": "account/workspaceMessages/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": "externalAgentConfig/import/recordHistory", id: RequestId, params: ExternalAgentConfigImportHistoryRecordParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "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/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/section/move", id: RequestId, params: ThreadSectionMoveParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/revert", id: RequestId, params: ThreadRevertParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "threadSection/list", id: RequestId, params: ThreadSectionListParams, } | { "method": "threadSection/create", id: RequestId, params: ThreadSectionCreateParams, } | { "method": "threadSection/update", id: RequestId, params: ThreadSectionUpdateParams, } | { "method": "threadSection/delete", id: RequestId, params: ThreadSectionDeleteParams, } | { "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/items/list", id: RequestId, params: ThreadItemsListParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/reconcile", id: RequestId, params: PluginReconcileParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/read", id: RequestId, params: AppsReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "app/installed", id: RequestId, params: AppsInstalledParams, } | { "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": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "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": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "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?: GetAccountRateLimitsParams | undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params?: GetAccountTokenUsageParams | undefined, } | { "method": "account/workspaceMessages/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": "externalAgentConfig/import/recordHistory", id: RequestId, params: ExternalAgentConfigImportHistoryRecordParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "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/GetAccountRateLimitsParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsParams.ts new file mode 100644 index 0000000000..25669dcdd0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsParams.ts @@ -0,0 +1,18 @@ +// 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. + +/** + * Usage-read capabilities of the requesting client, never inferred from its experiment arm. + */ +export type GetAccountRateLimitsParams = { +/** + * The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, + * allow the backend to record experiment exposure after ordinary usage is blocked. + */ +supportsLunaReserve?: boolean, +/** + * Skip the separate reset-credit detail lookup for background usage polls. The usage + * response still includes the available count; omitted/false preserves detailed reads. + */ +excludeResetCreditDetails?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts index 548bd959a8..73762064eb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountRateLimitsResponse.ts @@ -6,6 +6,11 @@ import type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummar import type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type GetAccountRateLimitsResponse = { +/** + * Backend permission for ordinary included usage, validated against the active account. + * Null means unavailable; clients must not infer recovery from percentages or reset times. + */ +ordinaryUsageAllowed: boolean | null, /** * Backward-compatible single-bucket view; mirrors the historical payload. */ 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 fefe723385..532b3f8d0c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -192,6 +192,7 @@ export type { FsWatchResponse } from "./FsWatchResponse"; export type { FsWriteFileParams } from "./FsWriteFileParams"; export type { FsWriteFileResponse } from "./FsWriteFileResponse"; export type { GetAccountParams } from "./GetAccountParams"; +export type { GetAccountRateLimitsParams } from "./GetAccountRateLimitsParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; export type { GetAccountTokenUsageParams } from "./GetAccountTokenUsageParams"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index c830a9642a..da33b070c9 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -2154,6 +2154,11 @@ mod tests { "params?: GetAccountTokenUsageParams | undefined, }" ); assert!(client_request_ts.contains(LEGACY_ACCOUNT_USAGE_REQUEST)); + const LEGACY_ACCOUNT_RATE_LIMITS_REQUEST: &str = concat!( + "{ \"method\": \"account/rateLimits/read\", id: RequestId, ", + "params?: GetAccountRateLimitsParams | undefined, }" + ); + assert!(client_request_ts.contains(LEGACY_ACCOUNT_RATE_LIMITS_REQUEST)); let account_usage_response_ts = std::str::from_utf8( fixture_tree .get(Path::new("v2/GetAccountTokenUsageResponse.ts")) @@ -2232,12 +2237,16 @@ mod tests { }); let contents = std::str::from_utf8(contents)?; - // The stable usage RPC originally required `params: undefined`. Keep that exact - // legacy value accepted while extending the same method with optional thread params. - let legacy_account_usage_undefined = path == Path::new("ClientRequest.ts") - && contents.matches("| undefined").count() == 1 - && contents.contains(LEGACY_ACCOUNT_USAGE_REQUEST); - if contents.contains("| undefined") && !legacy_account_usage_undefined { + // Both stable usage RPCs originally required `params: undefined`. Preserve that + // source compatibility only for these exact envelopes, not arbitrary new fields. + let checked_contents = if path == Path::new("ClientRequest.ts") { + contents + .replace(LEGACY_ACCOUNT_USAGE_REQUEST, "") + .replace(LEGACY_ACCOUNT_RATE_LIMITS_REQUEST, "") + } else { + contents.to_owned() + }; + if checked_contents.contains("| undefined") { undefined_offenders.push(path.clone()); } diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 6c00f889f5..2660a332a2 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -493,6 +493,16 @@ enum GetAccountTokenUsageParamsTypeScript { Undefined, } +/// Preserve omitted/undefined params while exporting the new usage capability type. +#[allow(dead_code)] +#[derive(TS)] +#[ts(untagged)] +enum GetAccountRateLimitsParamsTypeScript { + Params(v2::GetAccountRateLimitsParams), + #[ts(type = "undefined")] + Undefined, +} + client_request_definitions! { Initialize => "initialize" { params: v1::InitializeParams, @@ -1232,7 +1242,7 @@ client_request_definitions! { }, GetAccountRateLimits => "account/rateLimits/read" { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + params: #[ts(optional, as = "Option", inline)] #[serde(default, skip_serializing_if = "Option::is_none")] v2::NullableGetAccountRateLimitsParams, serialization: None, response: v2::GetAccountRateLimitsResponse, }, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 47990a49a7..cb60fded98 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -308,10 +308,30 @@ impl fmt::Debug for ChatgptAuthTokensRefreshResponse { } } +/// Usage-read capabilities of the requesting client, never inferred from its experiment arm. +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountRateLimitsParams { + /// The client supports automatic Luna Reserve fallback. For eligible ChatGPT CLI users, + /// allow the backend to record experiment exposure after ordinary usage is blocked. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub supports_luna_reserve: bool, + /// Skip the separate reset-credit detail lookup for background usage polls. The usage + /// response still includes the available count; omitted/false preserves detailed reads. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_reset_credit_details: bool, +} + +pub type NullableGetAccountRateLimitsParams = Option; + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct GetAccountRateLimitsResponse { + /// Backend permission for ordinary included usage, validated against the active account. + /// Null means unavailable; clients must not infer recovery from percentages or reset times. + pub ordinary_usage_allowed: Option, /// Backward-compatible single-bucket view; mirrors the historical payload. pub rate_limits: RateLimitSnapshot, /// Multi-bucket view keyed by metered `limit_id` (for example, `codex`). diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9d5fc872f7..f067c2bbec 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -2750,11 +2750,25 @@ Codex-managed credentials are removed; AWS profiles, environment credentials, an ### 7) Rate limits (ChatGPT) +Clients that implement automatic Luna Reserve fallback may send +`"params": { "supportsLunaReserve": true }` on `account/rateLimits/read`. For eligible +ChatGPT CLI users, this opts into experiment exposure only after the backend reports +ordinary included usage blocked, for both control and treatment. It does not grant +Reserve access. Omitted params preserve non-exposing reads; API-key, PAT, and FedRAMP +sessions do not opt in. Clients connecting to older app servers that reject object +params should retry without params. + +Background polls may also send `"excludeResetCreditDetails": true` to avoid the +separate reset-credit detail lookup. The usage response still supplies the available +count. Startup and user-requested usage/reset reads should omit this flag so credit +details remain available; older servers ignore it and keep their existing behavior. + ```json { "method": "account/rateLimits/read", "id": 7 } { "id": 7, "result": { + "ordinaryUsageAllowed": true, "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, @@ -2787,6 +2801,7 @@ Field notes: - `rateLimitReachedType` identifies the backend-classified limit state when one has been reached. - `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value. - `accountId` identifies the account in the usage snapshot when the backend supplies it. +- `ordinaryUsageAllowed` is the backend decision for ordinary included usage, validated against the authenticated account and user. It is `null` for unavailable or mismatched identity data. A CLI task that automatically entered Luna Reserve can restore its previous model when a validated read allows included usage or reports usable credits, no backend banner or hard stop remains, and the user has not manually changed models. Percentages, reset timestamps, and sparse notifications do not authorize this transition. - `rateLimitUpsell` carries the optional backend-owned `rate_limit_upsell` object from the same usage request, preserving its nested snake_case fields. The backend controls eligibility; clients do not evaluate an experiment or issue another request for it. A missing, null, or unsupported banner leaves the existing client UI in place. Banners whose account or user does not match the authenticated identity are omitted. Sparse notifications do not clear this snapshot-only field. - `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. - `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 8e78c929e1..2ccc1e3fe5 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1592,8 +1592,8 @@ impl MessageProcessor { ClientRequest::GetAuthStatus { params, .. } => { self.account_processor.get_auth_status(params).await } - ClientRequest::GetAccountRateLimits { .. } => { - self.account_processor.get_account_rate_limits().await + ClientRequest::GetAccountRateLimits { params, .. } => { + self.account_processor.get_account_rate_limits(params).await } ClientRequest::ConsumeAccountRateLimitResetCredit { params, .. } => { self.account_processor diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index d3e9165a7c..72378bfb4e 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -7,6 +7,7 @@ use crate::auth_mode::auth_mode_to_api; use crate::external_auth::ExternalAuthBridge; use chrono::DateTime; use codex_app_server_protocol::DesktopOnboardingEntrypoint; +use codex_app_server_protocol::GetAccountRateLimitsParams; use codex_login::LoginOnboardingEntrypoint; use codex_login::login_with_bedrock_access_keys; use codex_model_provider::is_supported_amazon_bedrock_region; @@ -154,8 +155,9 @@ impl AccountRequestProcessor { pub(crate) async fn get_account_rate_limits( &self, + params: Option, ) -> Result, JSONRPCErrorError> { - self.get_account_rate_limits_response() + self.get_account_rate_limits_response(params.unwrap_or_default()) .await .map(|response| Some(response.into())) } @@ -1130,6 +1132,7 @@ impl AccountRequestProcessor { async fn get_account_rate_limits_response( &self, + params: GetAccountRateLimitsParams, ) -> Result { let Some(auth) = self.auth_manager.auth().await else { return Err(invalid_request( @@ -1149,10 +1152,23 @@ impl AccountRequestProcessor { self.config.http_client_factory(), ); - let (response, detailed_rate_limit_reset_credits) = tokio::join!( - client.get_rate_limits_with_reset_credits(), - Self::detailed_rate_limit_reset_credits(&client), - ); + let usage_request = async { + if params.supports_luna_reserve + && auth.auth_mode() == codex_protocol::auth::AuthMode::Chatgpt + && !auth.is_fedramp_account() + { + client.get_rate_limits_with_luna_reserve().await + } else { + client.get_rate_limits_with_reset_credits().await + } + }; + let (response, detailed_rate_limit_reset_credits) = tokio::join!(usage_request, async { + if params.exclude_reset_credit_details { + None + } else { + Self::detailed_rate_limit_reset_credits(&client).await + } + },); let response = response .map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?; if response.rate_limits.is_empty() { @@ -1191,15 +1207,19 @@ impl AccountRequestProcessor { // Match desktop's account readiness check before exposing account-bound CTA content. // Normal rate limits remain available when older backends omit identity or banner data. - let rate_limit_upsell = response.rate_limit_upsell.filter(|_| { - !auth.is_fedramp_account() - && response.account_id.is_some() - && response.account_id == auth.get_account_id() - && response.user_id.is_some() - && response.user_id == auth.get_chatgpt_user_id() - }); + let matches_active_account = !auth.is_fedramp_account() + && response.account_id.is_some() + && response.account_id == auth.get_account_id() + && response.user_id.is_some() + && response.user_id == auth.get_chatgpt_user_id(); + let rate_limit_upsell = response + .rate_limit_upsell + .filter(|_| matches_active_account); Ok(GetAccountRateLimitsResponse { + ordinary_usage_allowed: response + .ordinary_usage_allowed + .filter(|_| matches_active_account), rate_limits: rate_limits.into(), rate_limits_by_limit_id: Some( rate_limits_by_limit_id diff --git a/codex-rs/app-server/tests/suite/v2/luna_reserve.rs b/codex-rs/app-server/tests/suite/v2/luna_reserve.rs new file mode 100644 index 0000000000..37d6b17859 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/luna_reserve.rs @@ -0,0 +1,127 @@ +//! Exercise usage capability opt-in and lightweight polling through public JSON-RPC requests. + +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::ChatGptIdTokenClaims; +use app_test_support::TestAppServer; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use test_case::test_case; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[derive(Clone, Copy, PartialEq)] +enum AuthKind { + Chatgpt, + Fedramp, + Pat, +} + +#[test_case(AuthKind::Chatgpt, /*params*/ None, /*expect_header*/ false; "omitted_params")] +#[test_case(AuthKind::Chatgpt, Some(Value::Null), /*expect_header*/ false; "null_params")] +#[test_case(AuthKind::Chatgpt, Some(json!({})), /*expect_header*/ false; "default_capability")] +#[test_case(AuthKind::Chatgpt, Some(json!({"supportsLunaReserve": false})), /*expect_header*/ false; "disabled_capability")] +#[test_case(AuthKind::Chatgpt, Some(json!({"supportsLunaReserve": true})), /*expect_header*/ true; "eligible_client")] +#[test_case(AuthKind::Fedramp, Some(json!({"supportsLunaReserve": true})), /*expect_header*/ false; "restricted_account")] +#[test_case(AuthKind::Pat, Some(json!({"supportsLunaReserve": true})), /*expect_header*/ false; "personal_access_token")] +#[test_case(AuthKind::Chatgpt, Some(json!({"supportsLunaReserve": true, "excludeResetCreditDetails": true})), /*expect_header*/ true; "lightweight_poll")] +#[tokio::test] +async fn luna_reserve_usage_capability( + auth_kind: AuthKind, + params: Option, + expect_header: bool, +) -> Result<()> { + let home = TempDir::new()?; + let backend = MockServer::start().await; + let backend_url = backend.uri(); + std::fs::write( + home.path().join("config.toml"), + format!("chatgpt_base_url = \"{backend_url}\"\n"), + )?; + if auth_kind != AuthKind::Pat { + let mut claims = ChatGptIdTokenClaims::new().chatgpt_user_id("user-a"); + claims.chatgpt_account_is_fedramp = auth_kind == AuthKind::Fedramp; + write_chatgpt_auth( + home.path(), + ChatGptAuthFixture::new("test-token") + .account_id("account-a") + .claims(claims), + AuthCredentialsStoreMode::File, + )?; + } + Mock::given(method("GET")) + .and(path("/v1/user-auth-credential/whoami")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "email": null, "chatgpt_user_id": "user-a", + "chatgpt_account_id": "account-a", "chatgpt_plan_type": "pro", + "chatgpt_account_is_fedramp": false + }))) + .mount(&backend) + .await; + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "account_id": "account-a", "user_id": "user-a", "plan_type": "pro", + "rate_limit": {"allowed": false, "limit_reached": true}, + "rate_limit_reset_credits": {"available_count": 2} + }))) + .expect(/*r*/ 1) + .mount(&backend) + .await; + let lightweight = params + .as_ref() + .is_some_and(|params| params["excludeResetCreditDetails"] == true); + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({ + "available_count": 2, "credits": [] + }))) + .expect(if lightweight { 0 } else { 1 }) + .mount(&backend) + .await; + let mut app = TestAppServer::builder() + .with_codex_home(home.path()) + .without_auto_env() + .with_env_overrides(&[ + ("OPENAI_API_KEY", None), + ( + "CODEX_ACCESS_TOKEN", + (auth_kind == AuthKind::Pat).then_some("at-test-token"), + ), + ("CODEX_AUTHAPI_BASE_URL", Some(backend_url.as_str())), + ]) + .build_initialized() + .await?; + let request = app.send_request("account/rateLimits/read", params).await?; + let response: GetAccountRateLimitsResponse = app.read_response(request).await?; + assert_eq!( + response.rate_limit_reset_credits, + Some(RateLimitResetCreditsSummary { + available_count: 2, + credits: (!lightweight).then(Vec::new), + }) + ); + let requests = backend + .received_requests() + .await + .expect("recorded backend requests"); + let usage = requests + .iter() + .find(|request| request.url.path() == "/api/codex/usage") + .expect("usage request"); + assert_eq!( + usage.headers.contains_key("x-openai-codex-luna-reserve"), + expect_header + ); + backend.verify().await; + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index c7578dbed9..028ee5918f 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -44,6 +44,7 @@ mod hooks_list; mod host_skills; mod imagegen_extension; mod initialize; +mod luna_reserve; mod marketplace_add; mod marketplace_remove; mod marketplace_upgrade; 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 41d475b726..6a9d6064ac 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -249,6 +249,7 @@ async fn get_account_rate_limits_returns_snapshot( timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let expected = GetAccountRateLimitsResponse { + ordinary_usage_allowed: Some(true), account_id: Some("account-123".to_string()), rate_limit_upsell: Some(banner), rate_limits: RateLimitSnapshot { @@ -427,6 +428,7 @@ async fn get_account_rate_limits_filters_banner_by_identity( "primary": {"usedPercent": 42, "windowDurationMins": 60, "resetsAt": 2000000000} }); let expected: GetAccountRateLimitsResponse = serde_json::from_value(json!({ + "ordinaryUsageAllowed": if permitted { Some(true) } else { None }, "accountId": account, "rateLimitUpsell": if permitted { Some(banner) } else { None }, "rateLimits": snapshot, "rateLimitsByLimitId": {"codex": snapshot}, "rateLimitResetCredits": {"availableCount": 0, "credits": []} diff --git a/codex-rs/backend-client/src/client/rate_limit_resets.rs b/codex-rs/backend-client/src/client/rate_limit_resets.rs index 5ff565f4be..010fd744a4 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets.rs @@ -21,9 +21,30 @@ struct ConsumeRateLimitResetCreditRequest<'a> { impl Client { pub async fn get_rate_limits_with_reset_credits(&self) -> Result { - let payload = self.get_rate_limit_status().await?; + self.get_rate_limits_for_usage(/*supports_luna_reserve*/ false) + .await + } + + /// Opt in only for clients that can apply Reserve, not for passive account usage readers. + pub async fn get_rate_limits_with_luna_reserve(&self) -> Result { + self.get_rate_limits_for_usage(/*supports_luna_reserve*/ true) + .await + } + + async fn get_rate_limits_for_usage( + &self, + supports_luna_reserve: bool, + ) -> Result { + let payload = self.get_rate_limit_status(supports_luna_reserve).await?; + let ordinary_usage_allowed = payload + .rate_limits + .rate_limit + .as_ref() + .and_then(|limit| limit.as_deref()) + .map(|limit| limit.allowed); Ok(RateLimitsWithResetCredits { rate_limits: Self::rate_limit_snapshots_from_payload(payload.rate_limits), + ordinary_usage_allowed, rate_limit_reset_credits: payload.rate_limit_reset_credits, account_id: payload.account_id, user_id: payload.user_id, @@ -31,9 +52,15 @@ impl Client { }) } - pub(super) async fn get_rate_limit_status(&self) -> Result { + async fn get_rate_limit_status( + &self, + supports_luna_reserve: bool, + ) -> Result { let url = self.rate_limit_status_url(); - let req = self.request(Method::GET, &url).headers(self.headers()); + let mut req = self.request(Method::GET, &url).headers(self.headers()); + if supports_luna_reserve { + req = req.header("x-openai-codex-luna-reserve", HeaderValue::from_static("1")); + } let (body, ct) = self.exec_request(req, "GET", &url).await?; self.decode_json(&url, &ct, &body) } diff --git a/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs b/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs index 55703caab5..ad398641c9 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs @@ -1,9 +1,43 @@ +//! Usage permission and earned-reset payloads, including compatibility with older backends. + use super::*; use crate::types::ConsumeRateLimitResetCreditCode; use crate::types::RateLimitResetCreditDetails; use crate::types::RateLimitResetCreditsDetails; use crate::types::RateLimitResetCreditsSummary; use pretty_assertions::assert_eq; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +#[tokio::test] +async fn ordinary_usage_permission_comes_from_backend_not_display_percent() { + for (allowed, used_percent) in [(Some(true), 100), (Some(false), 0), (None, 0)] { + let server = MockServer::start().await; + let rate_limit = allowed.map(|allowed| { + serde_json::json!({ + "allowed": allowed, "limit_reached": !allowed, + "primary_window": {"used_percent": used_percent, "limit_window_seconds": 300, + "reset_after_seconds": 60, "reset_at": 2000000000} + }) + }); + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "plan_type": "plus", "rate_limit": rate_limit + }))) + .expect(1) + .mount(&server) + .await; + let response = test_client(&server.uri(), PathStyle::CodexApi) + .get_rate_limits_with_reset_credits() + .await + .unwrap(); + assert_eq!(response.ordinary_usage_allowed, allowed); + } +} #[test] fn rate_limit_reset_contract_uses_expected_paths_and_payloads() { diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index b5a2e960b5..4e1511c3b3 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -44,6 +44,8 @@ pub struct RateLimitResetCreditDetails { #[derive(Clone, Debug, PartialEq)] pub struct RateLimitsWithResetCredits { pub rate_limits: Vec, + /// Backend decision for ordinary included usage; absence is not permission to recover. + pub ordinary_usage_allowed: Option, pub rate_limit_reset_credits: Option, pub account_id: Option, pub user_id: Option, diff --git a/codex-rs/tui/src/app/tests/rate_limits.rs b/codex-rs/tui/src/app/tests/rate_limits.rs index 171c6d9eef..d3bfd7a49b 100644 --- a/codex-rs/tui/src/app/tests/rate_limits.rs +++ b/codex-rs/tui/src/app/tests/rate_limits.rs @@ -40,6 +40,7 @@ fn rate_limit_snapshot( fn account_rate_limits_response(snapshot: RateLimitSnapshot) -> GetAccountRateLimitsResponse { GetAccountRateLimitsResponse { + ordinary_usage_allowed: None, account_id: None, rate_limit_upsell: None, rate_limits: snapshot, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 13a5a42674..693b4c735b 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -2503,6 +2503,7 @@ mod tests { #[test] fn app_server_rate_limit_snapshots_deduplicates_top_level_limit_from_map() { let response = GetAccountRateLimitsResponse { + ordinary_usage_allowed: None, account_id: None, rate_limit_upsell: None, rate_limits: rate_limit_snapshot("codex"), diff --git a/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs b/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs index 2d3592f07b..bdb3ff9067 100644 --- a/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs @@ -16,6 +16,7 @@ fn banner_response( banner["presentation"] = json!(presentation); } GetAccountRateLimitsResponse { + ordinary_usage_allowed: None, account_id: Some("workspace-a".into()), rate_limit_upsell: Some(banner), rate_limits: snapshot(/*percent*/ 25.0),