diff --git a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/mod.rs b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/mod.rs index 31a086d0b..e8874881b 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/mod.rs @@ -85,6 +85,7 @@ fn merge_codex_reset_credit_detail_metadata( fn mark_codex_reset_credit_detail_failed( codex_metadata: &mut Map, + updated_at_unix_secs: u64, detail_error: impl Into, ) { let mut reset_credits = codex_metadata @@ -92,6 +93,7 @@ fn mark_codex_reset_credit_detail_failed( .and_then(Value::as_object) .cloned() .unwrap_or_default(); + 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!("failed")); reset_credits.insert( @@ -116,7 +118,7 @@ async fn enrich_codex_reset_credit_details( { Ok(request_spec) => request_spec, Err(message) => { - mark_codex_reset_credit_detail_failed(codex_metadata, message); + mark_codex_reset_credit_detail_failed(codex_metadata, now_unix_secs, message); return Ok(()); } }; @@ -129,6 +131,7 @@ async fn enrich_codex_reset_credit_details( ProviderQuotaExecutionOutcome::Failure(detail) => { mark_codex_reset_credit_detail_failed( codex_metadata, + now_unix_secs, format!("reset credit detail 请求执行失败: {detail}"), ); return Ok(()); @@ -140,6 +143,7 @@ async fn enrich_codex_reset_credit_details( .unwrap_or_else(|| format!("HTTP {}", result.status_code)); mark_codex_reset_credit_detail_failed( codex_metadata, + now_unix_secs, format!( "reset credit detail 返回状态码 {}: {detail}", result.status_code @@ -153,7 +157,11 @@ async fn enrich_codex_reset_credit_details( .as_ref() .and_then(|body| body.json_body.as_ref()) else { - mark_codex_reset_credit_detail_failed(codex_metadata, "无法解析 reset credit detail 响应"); + mark_codex_reset_credit_detail_failed( + codex_metadata, + now_unix_secs, + "无法解析 reset credit detail 响应", + ); return Ok(()); }; if let Some(detail_metadata) = @@ -161,7 +169,11 @@ async fn enrich_codex_reset_credit_details( { merge_codex_reset_credit_detail_metadata(codex_metadata, &detail_metadata); } else { - mark_codex_reset_credit_detail_failed(codex_metadata, "reset credit detail 响应为空"); + mark_codex_reset_credit_detail_failed( + codex_metadata, + now_unix_secs, + "reset credit detail 响应为空", + ); } Ok(()) @@ -792,4 +804,19 @@ mod tests { Some(&json!(2u64)) ); } + + #[test] + fn codex_reset_credit_detail_failure_records_attempt_time() { + let mut metadata = Map::new(); + + mark_codex_reset_credit_detail_failed(&mut metadata, 1_777_000_000, "request failed"); + + assert_eq!( + metadata + .get("reset_credits") + .and_then(Value::as_object) + .and_then(|credits| credits.get("updated_at")), + Some(&json!(1_777_000_000u64)) + ); + } } diff --git a/apps/aether-gateway/src/handlers/shared/catalog.rs b/apps/aether-gateway/src/handlers/shared/catalog.rs index 8e21c272a..15a3e4738 100644 --- a/apps/aether-gateway/src/handlers/shared/catalog.rs +++ b/apps/aether-gateway/src/handlers/shared/catalog.rs @@ -2054,6 +2054,29 @@ fn quota_snapshot_has_materialized_data( }) } +fn codex_upstream_metadata_is_at_least_as_fresh( + quota_snapshot: Option<&Map>, + upstream_metadata: Option<&Value>, +) -> bool { + let Some(metadata) = provider_quota_metadata_bucket(upstream_metadata, "codex") else { + return false; + }; + let Some(metadata_updated_at) = metadata + .get("updated_at") + .and_then(admin_provider_quota_pure::coerce_json_u64) + else { + return false; + }; + let snapshot_updated_at = quota_snapshot.and_then(|quota| { + quota + .get("updated_at") + .or_else(|| quota.get("observed_at")) + .and_then(admin_provider_quota_pure::coerce_json_u64) + }); + + snapshot_updated_at.is_none_or(|updated_at| metadata_updated_at >= updated_at) +} + fn windsurf_quota_snapshot_has_stale_cooldown(quota_snapshot: &Map) -> bool { let code = quota_snapshot .get("code") @@ -2121,8 +2144,15 @@ pub(crate) fn provider_key_status_snapshot_payload( .and_then(Value::as_object) .and_then(|snapshot| snapshot.get("quota")) .and_then(Value::as_object); + let refresh_codex_snapshot = provider_type.trim().eq_ignore_ascii_case("codex") + && codex_upstream_metadata_is_at_least_as_fresh( + quota_snapshot, + key.upstream_metadata.as_ref(), + ); - let payload = if quota_snapshot_has_materialized_data(quota_snapshot, provider_type) { + let payload = if quota_snapshot_has_materialized_data(quota_snapshot, provider_type) + && !refresh_codex_snapshot + { status_snapshot .cloned() .unwrap_or_else(default_provider_key_status_snapshot) @@ -3512,6 +3542,58 @@ mod tests { ); } + #[test] + fn provider_key_status_snapshot_payload_restores_complete_codex_cache() { + let mut key = sample_catalog_key(); + key.upstream_metadata = Some(json!({ + "codex": { + "updated_at": 200u64, + "plan_type": "plus", + "primary_used_percent": 89.0, + "primary_reset_at": 1_900_000_000u64, + "spark_primary_used_percent": 40.0, + "spark_primary_reset_at": 1_900_100_000u64, + "reset_credits": { + "available_count": 3, + "updated_at": 200u64, + "detail_status": "available", + "credits": [] + } + } + })); + key.status_snapshot = Some(json!({ + "quota": { + "version": 2, + "provider_type": "codex", + "updated_at": 200u64, + "windows": [{ + "code": "weekly", + "used_ratio": 1.0, + "reset_at": 1_900_000_000u64 + }] + } + })); + + let payload = provider_key_status_snapshot_payload(&key, "codex"); + let windows = payload["quota"]["windows"] + .as_array() + .expect("quota windows should exist"); + + assert_eq!(payload.pointer("/quota/plan_type"), Some(&json!("plus"))); + assert_eq!( + payload.pointer("/quota/reset_credits/available_count"), + Some(&json!(3u64)) + ); + assert!(windows.iter().any(|window| window["code"] == "spark_5h")); + assert_eq!( + windows + .iter() + .find(|window| window["code"] == "weekly") + .and_then(|window| window.get("used_ratio")), + Some(&json!(0.89)) + ); + } + #[test] fn sync_provider_key_quota_status_snapshot_preserves_codex_usage_state() { let current_status_snapshot = json!({ diff --git a/frontend/src/features/providers/components/ProviderDetailDrawer.vue b/frontend/src/features/providers/components/ProviderDetailDrawer.vue index 8dbdb2099..a1a52e63e 100644 --- a/frontend/src/features/providers/components/ProviderDetailDrawer.vue +++ b/frontend/src/features/providers/components/ProviderDetailDrawer.vue @@ -1020,6 +1020,7 @@ import { getCodexResetCreditAvailableCount as getCodexResetCreditAvailableCountFromSnapshot, getVisibleCodexResetCreditItems as getVisibleCodexResetCreditItemsFromSnapshot, mergeCodexQuotaDisplays, + shouldRefreshMissingCodexResetCredits, } from './codex-reset-credit-display' // 扩展端点类型,包含密钥列表 @@ -2443,8 +2444,12 @@ function shouldAutoRefreshCodexQuota(): boolean { if (isTokenExpiringSoon(key, now)) return true - // 旧缓存可能已有普通配额但没有 reset-credit 数据,仍需补拉一次 - if (!hasCodexQuotaDisplayData(key) || getCodexResetCreditAvailableCount(key) === null) { + // 旧缓存缺少 reset-credit 数据时补拉;近期已检查过则等待缓存过期 + if (!hasCodexQuotaDisplayData(key) || shouldRefreshMissingCodexResetCredits( + getCodexResetCreditsDisplay(key), + now, + AUTO_QUOTA_REFRESH_STALE_SECONDS, + )) { return true } // 配额数据超过 5 分钟未更新,也触发刷新 diff --git a/frontend/src/features/providers/components/__tests__/codex-reset-credit-display.spec.ts b/frontend/src/features/providers/components/__tests__/codex-reset-credit-display.spec.ts index 64d566d87..e2ea35ce3 100644 --- a/frontend/src/features/providers/components/__tests__/codex-reset-credit-display.spec.ts +++ b/frontend/src/features/providers/components/__tests__/codex-reset-credit-display.spec.ts @@ -7,6 +7,7 @@ import { getCodexResetCreditAvailableCount, getVisibleCodexResetCreditItems, mergeCodexQuotaDisplays, + shouldRefreshMissingCodexResetCredits, } from '@/features/providers/components/codex-reset-credit-display' import type { QuotaResetCreditsSnapshot } from '@/api/endpoints/types' @@ -59,6 +60,21 @@ describe('codex reset credit display helpers', () => { expect(getVisibleCodexResetCreditItems(snapshot, 1_700_000_000)).toEqual([]) }) + it('retries missing reset credits only when the cached detail check is stale', () => { + expect(shouldRefreshMissingCodexResetCredits(null, 1_700_000_000)).toBe(true) + expect(shouldRefreshMissingCodexResetCredits({ + detail_status: 'failed', + updated_at: 1_699_999_900, + }, 1_700_000_000)).toBe(false) + expect(shouldRefreshMissingCodexResetCredits({ + detail_status: 'failed', + updated_at: 1_699_999_600, + }, 1_700_000_000)).toBe(true) + expect(shouldRefreshMissingCodexResetCredits({ + available_count: 0, + }, 1_700_000_000)).toBe(false) + }) + it('sorts available detail items by remaining time and labels visible items with short ordinal keys', () => { const snapshot: QuotaResetCreditsSnapshot = { available_count: 7, diff --git a/frontend/src/features/providers/components/codex-reset-credit-display.ts b/frontend/src/features/providers/components/codex-reset-credit-display.ts index 07f437592..b6add4005 100644 --- a/frontend/src/features/providers/components/codex-reset-credit-display.ts +++ b/frontend/src/features/providers/components/codex-reset-credit-display.ts @@ -57,6 +57,18 @@ export function getCodexResetCreditAvailableCount( return typeof count === 'number' && Number.isFinite(count) && count >= 0 ? count : null } +export function shouldRefreshMissingCodexResetCredits( + snapshot: QuotaResetCreditsSnapshot | null | undefined, + nowUnixSecs = Math.floor(Date.now() / 1000), + staleSeconds = 5 * 60, +): boolean { + if (getCodexResetCreditAvailableCount(snapshot) !== null) return false + + const updatedAt = Number(snapshot?.updated_at) + if (!Number.isFinite(updatedAt)) return true + return (nowUnixSecs - updatedAt) > staleSeconds +} + export function formatCodexResetCreditCount(count: number | null | undefined): string { return `共 ${count ?? 0} 次机会` }