feat(app-server): expose rate-limit reset credits (#28143)

## Why

Codex users can earn personal rate-limit reset credits, but app-server
clients do not currently have an API for reading or redeeming them. This
adds the backend and protocol foundation used by the `/usage` TUI flow
in #28154.

## What changed

- Extend `account/rateLimits/read` with a nullable
`rateLimitResetCredits` summary sourced from the existing usage
response.
- Add backend-client and app-server support for consuming a reset with a
caller-generated idempotency key. A UUID is recommended, and clients
reuse the same key when retrying the same logical reset.
- Return only the consume `outcome`; clients refetch
`account/rateLimits/read` for updated window state.
- Document the response field and each consume outcome, and regenerate
the JSON and TypeScript schema fixtures.
- Clarify in `AGENTS.md` that new app-server string enum values use
camelCase on the wire.
- Update the existing TUI response fixture for the expanded protocol
shape.
- Add coverage for authentication, response mapping, backend failures,
consume outcomes, and request timeout behavior.

## Validation

- `just test -p codex-app-server-protocol` — 231 passed.
- `just test -p codex-backend-client` — 14 passed.
- Focused `codex-app-server` reset-credit tests — 5 passed.
- Focused `codex-tui` protocol response fixture test — passed.
- `just fix -p codex-backend-client -p codex-app-server-protocol -p
codex-app-server` — passed.
- `just fmt` — passed.
This commit is contained in:
jay
2026-06-15 21:54:01 +00:00
committed by GitHub
parent af99f6a72f
commit bef99f861b
31 changed files with 1060 additions and 84 deletions
+23 -5
View File
@@ -1866,7 +1866,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
- `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`.
- `account/logout` — sign out; triggers `account/updated`.
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available.
- `account/rateLimits/read` — fetch ChatGPT rate limits and an optional effective monthly credit limit; updates arrive via `account/rateLimits/updated` (notify).
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
@@ -1962,7 +1963,7 @@ Field notes:
```json
{ "method": "account/rateLimits/read", "id": 7 }
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } }
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "rateLimitResetCredits": { "availableCount": 2 } } }
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
```
@@ -1973,12 +1974,29 @@ Field notes:
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
- `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.
- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. Refetch `account/rateLimits/read` after consuming a reset.
### 8) Notify a workspace owner about a limit
### 8) Earned rate-limit resets (ChatGPT)
```json
{ "method": "account/sendAddCreditsNudgeEmail", "id": 8, "params": { "creditType": "credits" } }
{ "id": 8, "result": { "status": "sent" } }
{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868" } }
{ "id": 8, "result": { "outcome": "reset" } }
```
Field notes:
- `idempotencyKey` must be non-empty. A UUID is recommended for each logical redemption attempt; reuse the same value when retrying that attempt.
- `reset` means a credit was consumed.
- `alreadyRedeemed` means the same redemption completed previously. Treat it as an idempotent success and refresh account limits.
- `nothingToReset` means there is no eligible rate-limit window to reset.
- `noCredit` means the account has no earned reset credits available.
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
### 9) Notify a workspace owner about a limit
```json
{ "method": "account/sendAddCreditsNudgeEmail", "id": 9, "params": { "creditType": "credits" } }
{ "id": 9, "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`.
@@ -1376,6 +1376,11 @@ impl MessageProcessor {
ClientRequest::GetAccountRateLimits { .. } => {
self.account_processor.get_account_rate_limits().await
}
ClientRequest::ConsumeAccountRateLimitResetCredit { params, .. } => {
self.account_processor
.consume_account_rate_limit_reset_credit(params)
.await
}
ClientRequest::GetAccountTokenUsage { .. } => {
self.account_processor.get_account_token_usage().await
}
@@ -52,6 +52,9 @@ use codex_app_server_protocol::CommandExecResizeParams;
use codex_app_server_protocol::CommandExecTerminateParams;
use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolFunctionSpec;
@@ -149,6 +152,7 @@ use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
@@ -279,6 +283,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
use codex_backend_client::TokenUsageProfile;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
@@ -401,7 +406,6 @@ use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RealtimeVoicesList;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery;
@@ -1,5 +1,7 @@
use super::*;
mod rate_limit_resets;
// Duration before a browser ChatGPT login attempt is abandoned.
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
@@ -851,19 +853,64 @@ impl AccountRequestProcessor {
async fn get_account_rate_limits_response(
&self,
) -> Result<GetAccountRateLimitsResponse, JSONRPCErrorError> {
self.fetch_account_rate_limits()
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read rate limits",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read rate limits",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let response = client
.get_rate_limits_with_reset_credits()
.await
.map(
|(rate_limits, rate_limits_by_limit_id)| GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
rate_limits_by_limit_id
.into_iter()
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
},
)
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if response.rate_limits.is_empty() {
return Err(internal_error(
"failed to fetch codex rate limits: no snapshots returned",
));
}
let rate_limits_by_limit_id: HashMap<_, _> = response
.rate_limits
.iter()
.cloned()
.map(|snapshot| {
let limit_id = snapshot
.limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
(limit_id, snapshot)
})
.collect();
let rate_limits = response
.rate_limits
.iter()
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
.cloned()
.unwrap_or_else(|| response.rate_limits[0].clone());
Ok(GetAccountRateLimitsResponse {
rate_limits: rate_limits.into(),
rate_limits_by_limit_id: Some(
rate_limits_by_limit_id
.into_iter()
.map(|(limit_id, snapshot)| (limit_id, snapshot.into()))
.collect(),
),
rate_limit_reset_credits: response.rate_limit_reset_credits.map(|summary| {
RateLimitResetCreditsSummary {
available_count: summary.available_count,
}
}),
})
}
async fn get_account_token_usage_response(
@@ -963,61 +1010,6 @@ impl AccountRequestProcessor {
AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit,
}
}
async fn fetch_account_rate_limits(
&self,
) -> Result<
(
CoreRateLimitSnapshot,
HashMap<String, CoreRateLimitSnapshot>,
),
JSONRPCErrorError,
> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required to read rate limits",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required to read rate limits",
));
}
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
let snapshots = client
.get_rate_limits_many()
.await
.map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?;
if snapshots.is_empty() {
return Err(internal_error(
"failed to fetch codex rate limits: no snapshots returned",
));
}
let rate_limits_by_limit_id: HashMap<String, CoreRateLimitSnapshot> = snapshots
.iter()
.cloned()
.map(|snapshot| {
let limit_id = snapshot
.limit_id
.clone()
.unwrap_or_else(|| "codex".to_string());
(limit_id, snapshot)
})
.collect();
let primary = snapshots
.iter()
.find(|snapshot| snapshot.limit_id.as_deref() == Some("codex"))
.cloned()
.unwrap_or_else(|| snapshots[0].clone());
Ok((primary, rate_limits_by_limit_id))
}
}
#[cfg(test)]
@@ -0,0 +1,66 @@
use super::*;
const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
#[cfg(debug_assertions)]
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
impl AccountRequestProcessor {
pub(crate) async fn consume_account_rate_limit_reset_credit(
&self,
params: ConsumeAccountRateLimitResetCreditParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
if params.idempotency_key.is_empty() {
return Err(invalid_request("idempotencyKey must not be empty"));
}
let client = self.rate_limit_reset_backend_client().await?;
let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT;
#[cfg(debug_assertions)]
let request_timeout = std::env::var(RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(request_timeout);
let response = tokio::time::timeout(
request_timeout,
client.consume_rate_limit_reset_credit(&params.idempotency_key),
)
.await
.map_err(|_| internal_error("rate limit reset consume timed out"))?
.map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?;
let outcome = match response.code {
BackendConsumeRateLimitResetCreditCode::Reset => {
ConsumeAccountRateLimitResetCreditOutcome::Reset
}
BackendConsumeRateLimitResetCreditCode::NothingToReset => {
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset
}
BackendConsumeRateLimitResetCreditCode::NoCredit => {
ConsumeAccountRateLimitResetCreditOutcome::NoCredit
}
BackendConsumeRateLimitResetCreditCode::AlreadyRedeemed => {
ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed
}
};
Ok(Some(
ConsumeAccountRateLimitResetCreditResponse { outcome }.into(),
))
}
async fn rate_limit_reset_backend_client(&self) -> Result<BackendClient, JSONRPCErrorError> {
let Some(auth) = self.auth_manager.auth().await else {
return Err(invalid_request(
"codex account authentication required for rate limit reset credits",
));
};
if !auth.uses_codex_backend() {
return Err(invalid_request(
"chatgpt authentication required for rate limit reset credits",
));
}
BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))
}
}
@@ -24,6 +24,7 @@ use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FsCopyParams;
@@ -382,6 +383,18 @@ impl TestAppServer {
.await
}
/// Send an `account/rateLimitResetCredit/consume` JSON-RPC request.
pub async fn send_consume_account_rate_limit_reset_credit_request(
&mut self,
params: ConsumeAccountRateLimitResetCreditParams,
) -> anyhow::Result<i64> {
self.send_request(
"account/rateLimitResetCredit/consume",
Some(serde_json::to_value(params)?),
)
.await
}
/// Send an `account/sendAddCreditsNudgeEmail` JSON-RPC request.
pub async fn send_add_credits_nudge_email_request(
&mut self,
@@ -41,6 +41,7 @@ mod plugin_read;
mod plugin_share;
mod plugin_uninstall;
mod process_exec;
mod rate_limit_reset_credits;
mod rate_limits;
mod realtime_conversation;
mod remote_control;
@@ -0,0 +1,288 @@
use std::path::Path;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditParams;
use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use pretty_assertions::assert_eq;
use serde::de::DeserializeOwned;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 10);
const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str =
"CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS";
const SERVER_TIMEOUT_READ_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(/*secs*/ 15);
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
const INTERNAL_ERROR_CODE: i64 = -32603;
#[tokio::test]
async fn consume_rate_limit_reset_credit_requires_chatgpt_auth() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let consume_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: "request-1".to_string(),
},
)
.await?;
let consume_error = read_error_response(&mut mcp, consume_id).await?;
assert_eq!(consume_error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(
consume_error.error.message,
"codex account authentication required for rate limit reset credits"
);
login_with_api_key(&mut mcp, "sk-test-key").await?;
let consume_id = send_consume_reset_credit(&mut mcp, "request-2").await?;
let consume_error = read_error_response(&mut mcp, consume_id).await?;
assert_eq!(consume_error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(
consume_error.error.message,
"chatgpt authentication required for rate limit reset credits"
);
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_maps_backend_outcomes() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
let cases = [
(
"request-reset",
"reset",
ConsumeAccountRateLimitResetCreditOutcome::Reset,
2,
),
(
"request-nothing",
"nothing_to_reset",
ConsumeAccountRateLimitResetCreditOutcome::NothingToReset,
0,
),
(
"request-no-credit",
"no_credit",
ConsumeAccountRateLimitResetCreditOutcome::NoCredit,
0,
),
(
"request-retry",
"already_redeemed",
ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed,
0,
),
];
for (idempotency_key, backend_code, _, windows_reset) in cases {
Mock::given(method("POST"))
.and(path("/api/codex/rate-limit-reset-credits/consume"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.and(body_json(json!({ "redeem_request_id": idempotency_key })))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"code": backend_code,
"windows_reset": windows_reset
})))
.mount(&server)
.await;
}
let mut mcp = initialized_app_server(codex_home.path()).await?;
for (idempotency_key, _, expected_outcome, _) in cases {
assert_eq!(
consume_reset_credit(&mut mcp, idempotency_key).await?,
ConsumeAccountRateLimitResetCreditResponse {
outcome: expected_outcome,
}
);
}
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() -> Result<()> {
let (codex_home, _server) = chatgpt_test_context().await?;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = mcp
.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: String::new(),
},
)
.await?;
let error = read_error_response(&mut mcp, request_id).await?;
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(error.error.message, "idempotencyKey must not be empty");
Ok(())
}
#[tokio::test]
async fn consume_account_rate_limit_reset_credit_surfaces_backend_failure() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
Mock::given(method("POST"))
.and(path("/api/codex/rate-limit-reset-credits/consume"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;
let mut mcp = initialized_app_server(codex_home.path()).await?;
let request_id = send_consume_reset_credit(&mut mcp, "request-1").await?;
let error = read_error_response(&mut mcp, request_id).await?;
assert_eq!(error.error.code, INTERNAL_ERROR_CODE);
assert!(
error
.error
.message
.contains("failed to consume rate limit reset"),
"unexpected error message: {}",
error.error.message
);
Ok(())
}
#[tokio::test]
async fn consume_timeout_releases_account_auth_queue() -> Result<()> {
let (codex_home, server) = chatgpt_test_context().await?;
Mock::given(method("POST"))
.and(path("/api/codex/rate-limit-reset-credits/consume"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(std::time::Duration::from_secs(/*secs*/ 1))
.set_body_json(json!({ "code": "reset", "windows_reset": 2 })),
)
.mount(&server)
.await;
let mut mcp = TestAppServer::new_with_env(
codex_home.path(),
&[
("OPENAI_API_KEY", None),
(RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR, Some("100")),
],
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let consume_id = send_consume_reset_credit(&mut mcp, "request-timeout").await?;
let account_id = mcp
.send_get_account_request(GetAccountParams {
refresh_token: false,
})
.await?;
let consume_error: JSONRPCError = timeout(
SERVER_TIMEOUT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(consume_id)),
)
.await??;
assert_eq!(consume_error.error.code, INTERNAL_ERROR_CODE);
assert_eq!(
consume_error.error.message,
"rate limit reset consume timed out"
);
let account_error = read_error_response(&mut mcp, account_id).await?;
assert_eq!(account_error.error.code, INVALID_REQUEST_ERROR_CODE);
assert_eq!(
account_error.error.message,
"email and plan type are required for chatgpt authentication"
);
Ok(())
}
async fn chatgpt_test_context() -> Result<(TempDir, MockServer)> {
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;
write_chatgpt_base_url(codex_home.path(), &server.uri())?;
Ok((codex_home, server))
}
async fn initialized_app_server(codex_home: &Path) -> Result<TestAppServer> {
let mut mcp = TestAppServer::new_with_env(codex_home, &[("OPENAI_API_KEY", None)]).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
Ok(mcp)
}
async fn consume_reset_credit(
mcp: &mut TestAppServer,
idempotency_key: &str,
) -> Result<ConsumeAccountRateLimitResetCreditResponse> {
let request_id = send_consume_reset_credit(mcp, idempotency_key).await?;
read_response(mcp, request_id).await
}
async fn send_consume_reset_credit(mcp: &mut TestAppServer, idempotency_key: &str) -> Result<i64> {
mcp.send_consume_account_rate_limit_reset_credit_request(
ConsumeAccountRateLimitResetCreditParams {
idempotency_key: idempotency_key.to_string(),
},
)
.await
}
async fn read_response<T>(mcp: &mut TestAppServer, request_id: i64) -> Result<T>
where
T: DeserializeOwned,
{
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
async fn read_error_response(mcp: &mut TestAppServer, request_id: i64) -> Result<JSONRPCError> {
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
Ok(error)
}
async fn login_with_api_key(mcp: &mut TestAppServer, api_key: &str) -> Result<()> {
let request_id = mcp.send_login_account_api_key_request(api_key).await?;
assert_eq!(
read_response::<LoginAccountResponse>(mcp, request_id).await?,
LoginAccountResponse::ApiKey {}
);
Ok(())
}
fn write_chatgpt_base_url(codex_home: &Path, base_url: &str) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!("chatgpt_base_url = \"{base_url}\"\n"),
)
}
@@ -10,6 +10,7 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RateLimitReachedType;
use codex_app_server_protocol::RateLimitResetCreditsSummary;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_app_server_protocol::RequestId;
@@ -157,7 +158,8 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
}
}
}
]
],
"rate_limit_reset_credits": { "available_count": 3 }
});
Mock::given(method("GET"))
@@ -165,6 +167,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(response_body))
.expect(1)
.mount(&server)
.await;
@@ -257,6 +260,7 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
.into_iter()
.collect(),
),
rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { available_count: 3 }),
};
assert_eq!(received, expected);