fix: codex free accounts incorrectly marked as quota exhausted (#366)

* fix: codex free accounts incorrectly marked as quota exhausted when skip_exhausted_accounts enabled

- Cross-validate stored exhausted flag against window used_ratio in snapshot reader
- Guard has_credits:false check with window data presence in upstream_metadata fallback
- Add windows.is_empty() precondition to exhausted_by_credits in snapshot builder

Free codex accounts have has_credits:false (they use window-based quotas, not credits),
but the old logic treated this as credits depleted, skipping actual window usage checks.
This caused all free accounts to be incorrectly marked exhausted.

* test: cover codex free quota windows

---------

Co-authored-by: root <root@ser488556369540.local>
Co-authored-by: fawney19 <elky0401@gmail.com>
This commit is contained in:
jiuwovo-ai
2026-05-02 14:11:29 +08:00
committed by GitHub
parent c130d0e2c9
commit bfd1ea72b3
3 changed files with 113 additions and 10 deletions

View File

@@ -627,7 +627,7 @@ fn build_codex_quota_status_snapshot(
.min(); .min();
let reset_at = quota_windows_min_reset_at(&windows); let reset_at = quota_windows_min_reset_at(&windows);
let exhausted_by_credits = let exhausted_by_credits =
credits_unlimited != Some(true) && credits_has_credits == Some(false); windows.is_empty() && credits_unlimited != Some(true) && credits_has_credits == Some(false);
let exhausted_by_window = usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6); let exhausted_by_window = usage_ratio.is_some_and(|value| value >= 1.0 - 1e-6);
let exhausted = exhausted_by_credits || exhausted_by_window; let exhausted = exhausted_by_credits || exhausted_by_window;
@@ -1655,6 +1655,45 @@ mod tests {
); );
} }
#[test]
fn provider_key_status_snapshot_payload_keeps_codex_free_window_quota_available() {
let mut key = sample_catalog_key();
key.upstream_metadata = Some(json!({
"codex": {
"updated_at": 1_775_553_285u64,
"plan_type": "free",
"primary_used_percent": 64.0,
"primary_reset_at": 1_900_000_000u64,
"secondary_used_percent": 3.0,
"secondary_reset_at": 1_900_500_000u64,
"has_credits": false,
"credits_balance": 0.0,
"credits_unlimited": false
}
}));
let payload = provider_key_status_snapshot_payload(&key, "codex");
let quota = payload
.get("quota")
.and_then(Value::as_object)
.expect("quota snapshot should be object");
assert_eq!(quota.get("code"), Some(&json!("ok")));
assert_eq!(quota.get("exhausted"), Some(&json!(false)));
assert_eq!(quota.get("usage_ratio"), Some(&json!(0.64)));
assert_eq!(
quota
.get("credits")
.and_then(Value::as_object)
.and_then(|credits| credits.get("has_credits")),
Some(&json!(false))
);
assert_eq!(
quota.get("windows").and_then(Value::as_array).map(Vec::len),
Some(2usize)
);
}
#[test] #[test]
fn provider_key_status_snapshot_payload_derives_codex_reset_at_from_countdown() { fn provider_key_status_snapshot_payload_derives_codex_reset_at_from_countdown() {
let mut key = sample_catalog_key(); let mut key = sample_catalog_key();

View File

@@ -1779,7 +1779,7 @@ async fn gateway_prefers_status_snapshot_codex_quota_over_stale_metadata() {
} }
#[tokio::test] #[tokio::test]
async fn gateway_shows_codex_quota_reset_for_exhausted_zero_usage_snapshot() { async fn gateway_treats_stale_codex_exhausted_snapshot_as_available_when_windows_have_capacity() {
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields( let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
true, true,
false, false,
@@ -1881,11 +1881,8 @@ async fn gateway_shows_codex_quota_reset_for_exhausted_zero_usage_snapshot() {
.expect("json body should parse"); .expect("json body should parse");
let keys = payload["keys"].as_array().expect("keys should be array"); let keys = payload["keys"].as_array().expect("keys should be array");
assert_eq!(keys[0]["scheduling_status"], json!("blocked")); assert_eq!(keys[0]["scheduling_status"], json!("available"));
assert_eq!( assert_eq!(keys[0]["scheduling_reason"], json!("available"));
keys[0]["scheduling_reason"],
json!("account_quota_exhausted")
);
assert_eq!( assert_eq!(
keys[0]["account_quota"], keys[0]["account_quota"],
json!("周剩余 100.0% (7天0小时后重置) | 5H剩余 100.0% (5小时0分钟后重置)") json!("周剩余 100.0% (7天0小时后重置) | 5H剩余 100.0% (5小时0分钟后重置)")

View File

@@ -183,8 +183,28 @@ pub fn admin_pool_key_account_quota_exhausted(
key: &StoredProviderCatalogKey, key: &StoredProviderCatalogKey,
provider_type: &str, provider_type: &str,
) -> bool { ) -> bool {
if let Some(exhausted) = admin_pool_key_quota_snapshot(key, provider_type) if let Some(exhausted) =
.and_then(|quota_snapshot| admin_pool_json_bool(quota_snapshot.get("exhausted"))) admin_pool_key_quota_snapshot(key, provider_type).and_then(|quota_snapshot| {
let exhausted = admin_pool_json_bool(quota_snapshot.get("exhausted"))?;
if exhausted {
let windows_max_ratio = quota_snapshot
.get("windows")
.and_then(Value::as_array)
.filter(|w| !w.is_empty())
.and_then(|windows| {
windows
.iter()
.filter_map(Value::as_object)
.filter_map(|w| w.get("used_ratio"))
.filter_map(Value::as_f64)
.max_by(f64::total_cmp)
});
if windows_max_ratio.is_some_and(|ratio| ratio < 1.0 - 1e-6) {
return Some(false);
}
}
Some(exhausted)
})
{ {
return exhausted; return exhausted;
} }
@@ -200,7 +220,9 @@ pub fn admin_pool_key_account_quota_exhausted(
if admin_pool_json_bool(bucket.get("credits_unlimited")) == Some(true) { if admin_pool_json_bool(bucket.get("credits_unlimited")) == Some(true) {
return false; return false;
} }
if admin_pool_json_bool(bucket.get("has_credits")) == Some(false) { let has_window_data = admin_pool_json_f64(bucket.get("primary_used_percent")).is_some()
|| admin_pool_json_f64(bucket.get("secondary_used_percent")).is_some();
if !has_window_data && admin_pool_json_bool(bucket.get("has_credits")) == Some(false) {
return true; return true;
} }
admin_pool_json_f64(bucket.get("primary_used_percent")) admin_pool_json_f64(bucket.get("primary_used_percent"))
@@ -660,6 +682,17 @@ mod tests {
}))), }))),
"codex", "codex",
)); ));
assert!(!admin_pool_key_account_quota_exhausted(
&sample_key(Some(json!({
"codex": {
"has_credits": false,
"credits_unlimited": false,
"primary_used_percent": 64.0,
"secondary_used_percent": 3.0
}
}))),
"codex",
));
assert!(!admin_pool_key_account_quota_exhausted( assert!(!admin_pool_key_account_quota_exhausted(
&sample_key(Some(json!({ &sample_key(Some(json!({
"codex": { "codex": {
@@ -708,6 +741,40 @@ mod tests {
assert!(!admin_pool_key_account_quota_exhausted(&key, "codex")); assert!(!admin_pool_key_account_quota_exhausted(&key, "codex"));
} }
#[test]
fn clears_stale_codex_exhausted_snapshot_when_windows_have_capacity() {
let mut key = sample_key(Some(json!({
"codex": {
"has_credits": false,
"primary_used_percent": 100.0
}
})));
key.status_snapshot = Some(json!({
"quota": {
"version": 2,
"provider_type": "codex",
"code": "exhausted",
"exhausted": true,
"usage_ratio": 0.0,
"updated_at": 1_776_395_200u64,
"windows": [
{
"code": "weekly",
"used_ratio": 0.0,
"remaining_ratio": 1.0
},
{
"code": "5h",
"used_ratio": 0.0,
"remaining_ratio": 1.0
}
]
}
}));
assert!(!admin_pool_key_account_quota_exhausted(&key, "codex"));
}
#[test] #[test]
fn detects_kiro_exhaustion_from_metadata() { fn detects_kiro_exhaustion_from_metadata() {
assert!(admin_pool_key_account_quota_exhausted( assert!(admin_pool_key_account_quota_exhausted(