增加 Codex 重置次数功能

This commit is contained in:
MMEXA
2026-07-04 06:10:53 +08:00
parent b86d4e1f0c
commit ef77ec04ca
21 changed files with 1651 additions and 34 deletions
+322 -1
View File
@@ -601,6 +601,47 @@ fn codex_find_spark_rate_limit(
.and_then(serde_json::Value::as_object)
}
fn codex_reset_credits_container<'a>(
root: &'a serde_json::Map<String, serde_json::Value>,
) -> Option<&'a serde_json::Map<String, serde_json::Value>> {
[
"rate_limit_reset_credits",
"rateLimitResetCredits",
"reset_credits",
"resetCredits",
]
.iter()
.find_map(|key| root.get(*key).and_then(serde_json::Value::as_object))
}
fn codex_reset_credits_available_count(
root: &serde_json::Map<String, serde_json::Value>,
) -> Option<u64> {
let container = codex_reset_credits_container(root)?;
[
"available_count",
"availableCount",
"available",
"remaining",
"count",
]
.iter()
.find_map(|key| container.get(*key).and_then(coerce_json_u64))
}
fn codex_reset_credits_count_snapshot(
available_count: u64,
updated_at_unix_secs: u64,
) -> serde_json::Value {
json!({
"available_count": available_count,
"updated_at": updated_at_unix_secs,
"detail_source": "wham_usage",
"detail_status": "not_requested",
"credits": [],
})
}
pub fn parse_codex_wham_usage_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
@@ -668,6 +709,13 @@ pub fn parse_codex_wham_usage_response(
}
}
if let Some(available_count) = codex_reset_credits_available_count(root) {
result.insert(
"reset_credits".to_string(),
codex_reset_credits_count_snapshot(available_count, updated_at_unix_secs),
);
}
if result.is_empty() {
return None;
}
@@ -675,6 +723,189 @@ pub fn parse_codex_wham_usage_response(
Some(serde_json::Value::Object(result))
}
fn parse_codex_reset_credit_timestamp(value: Option<&serde_json::Value>) -> Option<u64> {
let value = value?;
if let Some(timestamp) = coerce_json_u64(value) {
return Some(if timestamp > 1_000_000_000_000 {
timestamp / 1000
} else {
timestamp
});
}
let raw = value.as_str()?.trim();
if raw.is_empty() {
return None;
}
chrono::DateTime::parse_from_rfc3339(raw)
.ok()
.and_then(|timestamp| u64::try_from(timestamp.timestamp()).ok())
}
fn codex_reset_credit_detail_items(value: &serde_json::Value) -> Option<&Vec<serde_json::Value>> {
first_json_value_by_paths(
value,
&[
&["credits"],
&["data"],
&["items"],
&["rate_limit_reset_credits", "credits"],
&["rate_limit_reset_credits", "data"],
&["rateLimitResetCredits", "credits"],
&["rateLimitResetCredits", "data"],
&["reset_credits", "credits"],
&["resetCredits", "credits"],
],
)
.and_then(serde_json::Value::as_array)
}
fn codex_reset_credit_id(object: &serde_json::Map<String, serde_json::Value>) -> Option<String> {
[
"id",
"credit_id",
"creditId",
"key",
"idempotency_key",
"idempotencyKey",
]
.iter()
.find_map(|key| coerce_json_string(object.get(*key)))
}
fn codex_reset_credit_display_key(id: &str) -> Option<String> {
id.split('-')
.next()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn codex_reset_credit_status(
object: &serde_json::Map<String, serde_json::Value>,
) -> Option<String> {
["status", "state"]
.iter()
.find_map(|key| coerce_json_string(object.get(*key)))
}
fn parse_codex_reset_credit_detail_item(item: &serde_json::Value) -> Option<serde_json::Value> {
let object = item.as_object()?;
let id = codex_reset_credit_id(object)?;
let display_key = codex_reset_credit_display_key(&id)?;
let expires_at = parse_codex_reset_credit_timestamp(
object
.get("expires_at")
.or_else(|| object.get("expiresAt"))
.or_else(|| object.get("expiration_time"))
.or_else(|| object.get("expirationTime")),
)?;
let granted_at = parse_codex_reset_credit_timestamp(
object
.get("granted_at")
.or_else(|| object.get("grantedAt"))
.or_else(|| object.get("created_at"))
.or_else(|| object.get("createdAt")),
);
let mut out = serde_json::Map::new();
out.insert("id".to_string(), json!(id));
out.insert("display_key".to_string(), json!(display_key));
if let Some(status) = codex_reset_credit_status(object) {
out.insert("status".to_string(), json!(status));
}
if let Some(granted_at) = granted_at {
out.insert("granted_at".to_string(), json!(granted_at));
}
out.insert("expires_at".to_string(), json!(expires_at));
Some(serde_json::Value::Object(out))
}
pub fn parse_codex_wham_reset_credits_detail_response(
value: &serde_json::Value,
updated_at_unix_secs: u64,
) -> Option<serde_json::Value> {
value.as_object()?;
let mut credits = codex_reset_credit_detail_items(value)
.into_iter()
.flatten()
.filter_map(parse_codex_reset_credit_detail_item)
.collect::<Vec<_>>();
credits.sort_by_key(|item| {
item.get("expires_at")
.and_then(coerce_json_u64)
.unwrap_or(u64::MAX)
});
let detail_status = if credits.is_empty() {
"empty"
} else {
"available"
};
let mut reset_credits = serde_json::Map::new();
reset_credits.insert("updated_at".to_string(), json!(updated_at_unix_secs));
reset_credits.insert("detail_source".to_string(), json!("wham_readonly"));
reset_credits.insert("detail_status".to_string(), json!(detail_status));
reset_credits.insert("credits".to_string(), serde_json::Value::Array(credits));
if let Some(root) = value.as_object() {
if let Some(available_count) = codex_reset_credits_available_count(root).or_else(|| {
[
"available_count",
"availableCount",
"available",
"remaining",
"count",
]
.iter()
.find_map(|key| root.get(*key).and_then(coerce_json_u64))
}) {
reset_credits.insert("available_count".to_string(), json!(available_count));
}
}
Some(json!({ "reset_credits": reset_credits }))
}
pub fn normalize_codex_reset_credit_consume_outcome(
value: Option<&serde_json::Value>,
) -> Option<String> {
let object = value.and_then(serde_json::Value::as_object)?;
let raw = ["outcome", "status", "result", "code"]
.iter()
.find_map(|key| coerce_json_string(object.get(*key)));
if let Some(raw) = raw {
let normalized = raw
.trim()
.replace('-', "_")
.replace(' ', "_")
.to_ascii_lowercase();
return match normalized.as_str() {
"reset" | "success" | "redeemed" => Some("reset".to_string()),
"alreadyredeemed" | "already_redeemed" => Some("already_redeemed".to_string()),
"nothingtoreset" | "nothing_to_reset" => Some("nothing_to_reset".to_string()),
"nocredit" | "no_credit" => Some("no_credit".to_string()),
"error" | "failed" => Some("error".to_string()),
_ => None,
};
}
for (field, outcome) in [
("reset", "reset"),
("alreadyRedeemed", "already_redeemed"),
("already_redeemed", "already_redeemed"),
("nothingToReset", "nothing_to_reset"),
("nothing_to_reset", "nothing_to_reset"),
("noCredit", "no_credit"),
("no_credit", "no_credit"),
] {
if object.get(field).and_then(coerce_json_bool) == Some(true) {
return Some(outcome.to_string());
}
}
None
}
fn codex_json_object<'a>(
root: &'a serde_json::Map<String, serde_json::Value>,
keys: &[&str],
@@ -1772,7 +2003,8 @@ pub fn parse_chatgpt_web_conversation_init_response(
mod tests {
use super::{
codex_build_invalid_state, codex_runtime_invalid_reason,
parse_chatgpt_web_conversation_init_response, parse_codex_backend_me_response,
normalize_codex_reset_credit_consume_outcome, parse_chatgpt_web_conversation_init_response,
parse_codex_backend_me_response, parse_codex_wham_reset_credits_detail_response,
parse_codex_wham_usage_response, parse_gemini_cli_retrieve_user_quota_response,
parse_gemini_cli_v1internal_credits_response, parse_windsurf_model_configs_response,
parse_windsurf_rate_limit_response, parse_windsurf_user_status_response,
@@ -2187,6 +2419,95 @@ mod tests {
);
}
#[test]
fn parses_codex_reset_credit_count_from_wham_usage() {
let parsed = parse_codex_wham_usage_response(
&json!({
"plan_type": "plus",
"rate_limit": {
"primary_window": {
"used_percent": 25.0,
"reset_after_seconds": 604800
}
},
"rate_limit_reset_credits": {
"available_count": 2
}
}),
1_777_000_000,
)
.expect("codex wham usage should parse");
assert_eq!(
parsed.pointer("/reset_credits/available_count"),
Some(&json!(2u64))
);
assert_eq!(
parsed.pointer("/reset_credits/detail_status"),
Some(&json!("not_requested"))
);
assert_eq!(
parsed.pointer("/reset_credits/updated_at"),
Some(&json!(1_777_000_000u64))
);
}
#[test]
fn parses_codex_reset_credit_detail_sorted_by_expiry() {
let parsed = parse_codex_wham_reset_credits_detail_response(
&json!({
"credits": [
{
"idempotencyKey": "bbbbbbbb-1111-2222-3333-444444444444",
"status": "available",
"expiresAt": "2030-01-04T00:00:00Z"
},
{
"idempotencyKey": "aaaaaaaa-1111-2222-3333-444444444444",
"status": "available",
"grantedAt": 1_893_456_000_000u64,
"expiresAt": "2030-01-02T00:00:00Z"
}
]
}),
1_777_000_000,
)
.expect("detail should parse");
assert_eq!(
parsed.pointer("/reset_credits/detail_status"),
Some(&json!("available"))
);
assert_eq!(
parsed.pointer("/reset_credits/credits/0/display_key"),
Some(&json!("aaaaaaaa"))
);
assert_eq!(
parsed.pointer("/reset_credits/credits/0/granted_at"),
Some(&json!(1_893_456_000u64))
);
assert_eq!(
parsed.pointer("/reset_credits/credits/1/display_key"),
Some(&json!("bbbbbbbb"))
);
}
#[test]
fn normalizes_codex_reset_credit_consume_outcome() {
assert_eq!(
normalize_codex_reset_credit_consume_outcome(Some(&json!({
"outcome": "alreadyRedeemed"
}))),
Some("already_redeemed".to_string())
);
assert_eq!(
normalize_codex_reset_credit_consume_outcome(Some(&json!({
"noCredit": true
}))),
Some("no_credit".to_string())
);
}
#[test]
fn parses_codex_backend_me_identity_metadata_without_quota_windows() {
let parsed = parse_codex_backend_me_response(
+63 -2
View File
@@ -16,7 +16,8 @@ pub use presets::{
pub use provider::{ProviderPoolAdapter, ProviderPoolMemberInput};
pub use providers::{
build_antigravity_pool_quota_request, build_chatgpt_web_pool_quota_request,
build_codex_pool_quota_request, build_gemini_cli_pool_quota_request,
build_codex_pool_quota_request, build_codex_pool_reset_credit_consume_request,
build_codex_pool_reset_credits_request, build_gemini_cli_pool_quota_request,
build_kiro_pool_quota_request, build_windsurf_pool_model_configs_request,
build_windsurf_pool_model_configs_request_with_base_url, build_windsurf_pool_quota_request,
build_windsurf_pool_quota_request_with_base_url, build_windsurf_pool_rate_limit_request,
@@ -27,7 +28,8 @@ pub use providers::{
DefaultProviderPoolAdapter, GeminiCliProviderPoolAdapter, GrokProviderPoolAdapter,
KiroPoolQuotaAuthInput, KiroProviderPoolAdapter, UnsupportedQuotaProviderPoolAdapter,
ANTIGRAVITY_FETCH_AVAILABLE_MODELS_PATH, CHATGPT_WEB_CONVERSATION_INIT_PATH,
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH,
CHATGPT_WEB_DEFAULT_BASE_URL, CODEX_WHAM_RESET_CREDITS_CONSUME_URL,
CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL, GEMINI_CLI_RETRIEVE_USER_QUOTA_PATH,
GEMINI_CLI_USER_AGENT, KIRO_USAGE_LIMITS_PATH, KIRO_USAGE_SDK_VERSION,
WINDSURF_MODEL_CONFIGS_PATH, WINDSURF_RATE_LIMIT_PATH, WINDSURF_USER_STATUS_PATH,
};
@@ -215,6 +217,65 @@ mod tests {
assert_eq!(spec.model_name.as_deref(), Some("codex-wham-usage"));
}
#[test]
fn codex_reset_credits_request_uses_wham_detail_endpoint() {
let spec = build_codex_pool_reset_credits_request(
"key-1",
Some(("authorization".to_string(), "Bearer access".to_string())),
None,
Some(&json!({
"plan_type": "plus",
"account_id": "acct-1"
})),
)
.expect("spec should build");
assert_eq!(spec.method, "GET");
assert_eq!(spec.url, CODEX_WHAM_RESET_CREDITS_URL);
assert_eq!(
spec.headers.get("authorization").map(String::as_str),
Some("Bearer access")
);
assert_eq!(
spec.headers.get("chatgpt-account-id").map(String::as_str),
Some("acct-1")
);
assert_eq!(spec.model_name.as_deref(), Some("codex-wham-reset-credits"));
}
#[test]
fn codex_reset_credit_consume_request_posts_redeem_request_id() {
let spec = build_codex_pool_reset_credit_consume_request(
"key-1",
Some(("authorization".to_string(), "Bearer access".to_string())),
None,
None,
"8ae6f1c7-7e9e-4f5d-9b8a-000000000000",
)
.expect("spec should build");
assert_eq!(spec.method, "POST");
assert_eq!(spec.url, CODEX_WHAM_RESET_CREDITS_CONSUME_URL);
assert_eq!(spec.content_type.as_deref(), Some("application/json"));
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.get("redeem_request_id")),
Some(&json!("8ae6f1c7-7e9e-4f5d-9b8a-000000000000"))
);
assert_eq!(
spec.json_body
.as_ref()
.and_then(|body| body.as_object())
.map(|body| body.len()),
Some(1)
);
assert_eq!(
spec.model_name.as_deref(),
Some("codex-wham-reset-credit-consume")
);
}
#[test]
fn codex_quota_request_skips_account_header_for_free_accounts() {
let spec = build_codex_pool_quota_request(
@@ -17,6 +17,10 @@ use crate::quota::{
use crate::quota_refresh::ProviderPoolQuotaRequestSpec;
pub const CODEX_WHAM_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
pub const CODEX_WHAM_RESET_CREDITS_URL: &str =
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
pub const CODEX_WHAM_RESET_CREDITS_CONSUME_URL: &str =
"https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
const PLACEHOLDER_API_KEY: &str = "__placeholder__";
#[derive(Debug, Clone, Default)]
@@ -68,12 +72,11 @@ impl ProviderPoolAdapter for CodexProviderPoolAdapter {
}
}
pub fn build_codex_pool_quota_request(
key_id: &str,
fn build_codex_wham_headers(
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
) -> Result<BTreeMap<String, String>, String> {
let mut headers = BTreeMap::new();
headers.insert("accept".to_string(), "application/json".to_string());
let auth_config_headers = auth_config
@@ -120,6 +123,17 @@ pub fn build_codex_pool_quota_request(
);
}
Ok(headers)
}
pub fn build_codex_pool_quota_request(
key_id: &str,
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let headers = build_codex_wham_headers(resolved_oauth_auth, decrypted_api_key, auth_config)?;
Ok(ProviderPoolQuotaRequestSpec {
request_id: format!("codex-quota:{key_id}"),
provider_name: "codex".to_string(),
@@ -136,6 +150,64 @@ pub fn build_codex_pool_quota_request(
})
}
pub fn build_codex_pool_reset_credits_request(
key_id: &str,
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let headers = build_codex_wham_headers(resolved_oauth_auth, decrypted_api_key, auth_config)?;
Ok(ProviderPoolQuotaRequestSpec {
request_id: format!("codex-reset-credits:{key_id}"),
provider_name: "codex".to_string(),
quota_kind: "codex_reset_credits".to_string(),
method: "GET".to_string(),
url: CODEX_WHAM_RESET_CREDITS_URL.to_string(),
headers,
content_type: None,
json_body: None,
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("codex-wham-reset-credits".to_string()),
accept_invalid_certs: false,
})
}
pub fn build_codex_pool_reset_credit_consume_request(
key_id: &str,
resolved_oauth_auth: Option<(String, String)>,
decrypted_api_key: Option<&str>,
auth_config: Option<&Value>,
redeem_request_id: &str,
) -> Result<ProviderPoolQuotaRequestSpec, String> {
let redeem_request_id = redeem_request_id.trim();
if redeem_request_id.is_empty() {
return Err("缺少 Codex reset credit 幂等请求 ID".to_string());
}
let mut headers =
build_codex_wham_headers(resolved_oauth_auth, decrypted_api_key, auth_config)?;
headers.insert("content-type".to_string(), "application/json".to_string());
Ok(ProviderPoolQuotaRequestSpec {
request_id: format!("codex-reset-credit-consume:{key_id}:{redeem_request_id}"),
provider_name: "codex".to_string(),
quota_kind: "codex_reset_credit_consume".to_string(),
method: "POST".to_string(),
url: CODEX_WHAM_RESET_CREDITS_CONSUME_URL.to_string(),
headers,
content_type: Some("application/json".to_string()),
json_body: Some(serde_json::json!({
"redeem_request_id": redeem_request_id,
})),
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("codex-wham-reset-credit-consume".to_string()),
accept_invalid_certs: false,
})
}
fn codex_window_reset_elapsed(bucket: &Map<String, Value>, prefix: &str) -> bool {
let Some(now_unix_secs) = provider_pool_current_unix_secs() else {
return false;
@@ -19,7 +19,11 @@ pub use chatgpt_web::{
CHATGPT_WEB_DEFAULT_BASE_URL,
};
pub use codex::CodexProviderPoolAdapter;
pub use codex::{build_codex_pool_quota_request, CODEX_WHAM_USAGE_URL};
pub use codex::{
build_codex_pool_quota_request, build_codex_pool_reset_credit_consume_request,
build_codex_pool_reset_credits_request, CODEX_WHAM_RESET_CREDITS_CONSUME_URL,
CODEX_WHAM_RESET_CREDITS_URL, CODEX_WHAM_USAGE_URL,
};
pub use default::DefaultProviderPoolAdapter;
pub use gemini_cli::GeminiCliProviderPoolAdapter;
pub use gemini_cli::{