增加 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(