mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
fix(codex): support dynamic quota windows
This commit is contained in:
+14
-4
@@ -106,7 +106,17 @@ fn reset_codex_cycle_usage_windows(status_snapshot: &mut Value, now_unix_secs: u
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !code.eq_ignore_ascii_case("5h") && !code.eq_ignore_ascii_case("weekly") {
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("account");
|
||||
let has_zero_window = window.get("window_minutes").and_then(Value::as_u64) == Some(0);
|
||||
if code.is_empty()
|
||||
|| !scope.eq_ignore_ascii_case("account")
|
||||
|| code.to_ascii_lowercase().starts_with("spark_")
|
||||
|| has_zero_window
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -177,7 +187,7 @@ mod tests {
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(reset_codex_cycle_usage_windows(&mut snapshot, 1_234), 2);
|
||||
assert_eq!(reset_codex_cycle_usage_windows(&mut snapshot, 1_234), 3);
|
||||
let windows = snapshot["quota"]["windows"].as_array().expect("windows");
|
||||
assert_eq!(windows[0]["usage_reset_at"], json!(1_234));
|
||||
assert_eq!(windows[0]["usage"]["request_count"], json!(0));
|
||||
@@ -187,7 +197,7 @@ mod tests {
|
||||
assert_eq!(windows[1]["usage"]["request_count"], json!(0));
|
||||
assert_eq!(windows[1]["usage"]["total_tokens"], json!(0));
|
||||
assert_eq!(windows[1]["usage"]["total_cost_usd"], json!("0.00000000"));
|
||||
assert!(windows[2].get("usage_reset_at").is_none());
|
||||
assert!(windows[2].get("usage").is_some());
|
||||
assert_eq!(windows[2]["usage_reset_at"], json!(1_234));
|
||||
assert_eq!(windows[2]["usage"]["request_count"], json!(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,12 +255,25 @@ fn admin_pool_quota_window_reset_seconds(
|
||||
|
||||
fn admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot: &serde_json::Map<String, serde_json::Value>,
|
||||
window_code: &str,
|
||||
label: &str,
|
||||
window: &serde_json::Map<String, serde_json::Value>,
|
||||
now_unix_secs: u64,
|
||||
show_reset_without_consumption: bool,
|
||||
) -> Option<String> {
|
||||
let window = admin_pool_quota_window(quota_snapshot, window_code)?;
|
||||
if admin_pool_json_to_u64(window.get("window_minutes")) == Some(0) {
|
||||
return None;
|
||||
}
|
||||
let label = window
|
||||
.get("label")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|label| !label.is_empty())
|
||||
.or_else(|| {
|
||||
window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|code| !code.is_empty())
|
||||
})?;
|
||||
let used_percent = admin_pool_quota_window_used_percent(window)?;
|
||||
let reset_seconds =
|
||||
admin_pool_quota_window_reset_seconds(quota_snapshot, window, now_unix_secs);
|
||||
@@ -294,23 +307,35 @@ fn admin_pool_build_codex_account_quota_from_snapshot(
|
||||
.and_then(admin_provider_quota_pure::coerce_json_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(part) = admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot,
|
||||
"weekly",
|
||||
"周",
|
||||
now_unix_secs,
|
||||
exhausted,
|
||||
) {
|
||||
parts.push(part);
|
||||
}
|
||||
if let Some(part) = admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot,
|
||||
"5h",
|
||||
"5H",
|
||||
now_unix_secs,
|
||||
exhausted,
|
||||
) {
|
||||
parts.push(part);
|
||||
if let Some(windows) = quota_snapshot
|
||||
.get("windows")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
{
|
||||
for window in windows.iter().filter_map(serde_json::Value::as_object) {
|
||||
let code = window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("account");
|
||||
if !scope.eq_ignore_ascii_case("account")
|
||||
|| code.to_ascii_lowercase().starts_with("spark_")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(part) = admin_pool_codex_quota_part_from_window(
|
||||
quota_snapshot,
|
||||
window,
|
||||
now_unix_secs,
|
||||
exhausted,
|
||||
) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !parts.is_empty() {
|
||||
@@ -345,6 +370,25 @@ fn admin_pool_current_unix_secs() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn admin_pool_is_regular_codex_cycle_window(
|
||||
window: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> bool {
|
||||
let code = window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("account");
|
||||
!code.is_empty()
|
||||
&& scope.eq_ignore_ascii_case("account")
|
||||
&& !code.to_ascii_lowercase().starts_with("spark_")
|
||||
&& admin_pool_json_to_u64(window.get("window_minutes")) != Some(0)
|
||||
}
|
||||
|
||||
fn admin_pool_prune_expired_codex_window_usage_at(
|
||||
status_snapshot: &mut serde_json::Value,
|
||||
now_unix_secs: u64,
|
||||
@@ -362,12 +406,7 @@ fn admin_pool_prune_expired_codex_window_usage_at(
|
||||
.iter_mut()
|
||||
.filter_map(serde_json::Value::as_object_mut)
|
||||
{
|
||||
let code = window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !code.eq_ignore_ascii_case("5h") && !code.eq_ignore_ascii_case("weekly") {
|
||||
if !admin_pool_is_regular_codex_cycle_window(window) {
|
||||
continue;
|
||||
}
|
||||
let Some(reset_at) = admin_pool_json_to_u64(window.get("reset_at")) else {
|
||||
@@ -1405,7 +1444,9 @@ mod tests {
|
||||
"quota": {
|
||||
"windows": [
|
||||
{
|
||||
"code": "5h",
|
||||
"code": "monthly",
|
||||
"scope": "account",
|
||||
"window_minutes": 43_800,
|
||||
"reset_at": 1,
|
||||
"usage": {
|
||||
"request_count": 7,
|
||||
@@ -1451,6 +1492,31 @@ mod tests {
|
||||
assert_eq!(usage["total_cost_usd"], json!("0.60000000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_monthly_quota_is_rendered_from_actual_snapshot_window() {
|
||||
let quota_snapshot = json!({
|
||||
"provider_type": "codex",
|
||||
"code": "ok",
|
||||
"exhausted": false,
|
||||
"windows": [
|
||||
{
|
||||
"code": "monthly",
|
||||
"label": "月",
|
||||
"scope": "account",
|
||||
"used_ratio": 0.14,
|
||||
"remaining_ratio": 0.86,
|
||||
"window_minutes": 43_800
|
||||
}
|
||||
]
|
||||
});
|
||||
let quota_snapshot = quota_snapshot.as_object().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
admin_pool_build_account_quota("codex", Some(quota_snapshot)),
|
||||
Some("月剩余 86.0%".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grok_model_quota_is_rendered_for_pool_rows() {
|
||||
let quota_snapshot = json!({
|
||||
|
||||
@@ -50,6 +50,8 @@ fn admin_pool_codex_default_window_minutes(code: &str) -> Option<u64> {
|
||||
Some(300)
|
||||
} else if code.eq_ignore_ascii_case("weekly") {
|
||||
Some(10_080)
|
||||
} else if code.eq_ignore_ascii_case("monthly") {
|
||||
Some(43_800)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -98,16 +100,27 @@ fn admin_pool_codex_cycle_usage_request(
|
||||
window: &serde_json::Map<String, serde_json::Value>,
|
||||
now_unix_secs: u64,
|
||||
) -> Option<ProviderApiKeyWindowUsageRequest> {
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("account");
|
||||
if !scope.eq_ignore_ascii_case("account") {
|
||||
return None;
|
||||
}
|
||||
let window_code = window
|
||||
.get("code")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|code| code.eq_ignore_ascii_case("5h") || code.eq_ignore_ascii_case("weekly"))?
|
||||
.filter(|code| !code.is_empty() && !code.to_ascii_lowercase().starts_with("spark_"))?
|
||||
.to_ascii_lowercase();
|
||||
let reset_at = admin_pool_json_u64(window.get("reset_at"))?;
|
||||
let window_seconds = admin_pool_json_u64(window.get("window_minutes"))
|
||||
.or_else(|| admin_pool_codex_default_window_minutes(&window_code))?
|
||||
.checked_mul(60)?;
|
||||
let window_minutes = match admin_pool_json_u64(window.get("window_minutes")) {
|
||||
Some(0) => return None,
|
||||
Some(value) => value,
|
||||
None => admin_pool_codex_default_window_minutes(&window_code)?,
|
||||
};
|
||||
let window_seconds = window_minutes.checked_mul(60)?;
|
||||
if reset_at <= now_unix_secs {
|
||||
return None;
|
||||
}
|
||||
@@ -788,6 +801,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cycle_usage_request_uses_actual_monthly_window_boundaries() {
|
||||
let key = sample_key("oauth");
|
||||
let reset_at = 5_000_000u64;
|
||||
let now = 3_000_000u64;
|
||||
let window = json!({
|
||||
"code": "monthly",
|
||||
"label": "月",
|
||||
"scope": "account",
|
||||
"reset_at": reset_at,
|
||||
"window_minutes": 43_800u64
|
||||
});
|
||||
|
||||
let request = admin_pool_codex_cycle_usage_request(
|
||||
&key,
|
||||
window.as_object().expect("window should be object"),
|
||||
now,
|
||||
)
|
||||
.expect("monthly usage request should build");
|
||||
|
||||
assert_eq!(request.window_code, "monthly");
|
||||
assert_eq!(request.start_unix_secs, reset_at - 43_800 * 60);
|
||||
assert_eq!(request.end_unix_secs, now);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_cycle_usage_request_ignores_zero_and_spark_windows() {
|
||||
let key = sample_key("oauth");
|
||||
for window in [
|
||||
json!({
|
||||
"code": "weekly",
|
||||
"scope": "account",
|
||||
"reset_at": 5_000_000u64,
|
||||
"window_minutes": 0
|
||||
}),
|
||||
json!({
|
||||
"code": "spark_weekly",
|
||||
"scope": "account",
|
||||
"reset_at": 5_000_000u64,
|
||||
"window_minutes": 10_080
|
||||
}),
|
||||
] {
|
||||
assert!(admin_pool_codex_cycle_usage_request(
|
||||
&key,
|
||||
window.as_object().expect("window should be object"),
|
||||
3_000_000,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_status_filter_prefers_catalog_key_expiry_over_auth_config_expiry() {
|
||||
let mut key = sample_key("oauth");
|
||||
|
||||
@@ -806,6 +806,50 @@ fn codex_default_window_minutes(code: &str) -> Option<u64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_quota_period_identity(window_minutes: u64) -> (String, String) {
|
||||
const MINUTES_PER_HOUR: u64 = 60;
|
||||
const MINUTES_PER_DAY: u64 = 24 * MINUTES_PER_HOUR;
|
||||
const MINUTES_PER_WEEK: u64 = 7 * MINUTES_PER_DAY;
|
||||
|
||||
if window_minutes == 5 * MINUTES_PER_HOUR {
|
||||
return ("5h".to_string(), "5H".to_string());
|
||||
}
|
||||
if window_minutes == MINUTES_PER_WEEK {
|
||||
return ("weekly".to_string(), "周".to_string());
|
||||
}
|
||||
if (28 * MINUTES_PER_DAY..=31 * MINUTES_PER_DAY).contains(&window_minutes) {
|
||||
return ("monthly".to_string(), "月".to_string());
|
||||
}
|
||||
|
||||
let label = if window_minutes % MINUTES_PER_WEEK == 0 {
|
||||
format!("{}周", window_minutes / MINUTES_PER_WEEK)
|
||||
} else if window_minutes % MINUTES_PER_DAY == 0 {
|
||||
format!("{}天", window_minutes / MINUTES_PER_DAY)
|
||||
} else if window_minutes % MINUTES_PER_HOUR == 0 {
|
||||
format!("{}H", window_minutes / MINUTES_PER_HOUR)
|
||||
} else {
|
||||
format!("{window_minutes}分钟")
|
||||
};
|
||||
(format!("window_{window_minutes}m"), label)
|
||||
}
|
||||
|
||||
fn codex_quota_window_identity(
|
||||
fallback_code: &str,
|
||||
fallback_label: &str,
|
||||
window_minutes: Option<u64>,
|
||||
) -> (String, String) {
|
||||
let Some(window_minutes) = window_minutes else {
|
||||
return (fallback_code.to_string(), fallback_label.to_string());
|
||||
};
|
||||
let is_spark = fallback_code.to_ascii_lowercase().starts_with("spark_");
|
||||
let (code, label) = codex_quota_period_identity(window_minutes);
|
||||
if is_spark {
|
||||
(format!("spark_{code}"), format!("Spark {label}"))
|
||||
} else {
|
||||
(code, label)
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_quota_window_snapshot(
|
||||
metadata: &Map<String, Value>,
|
||||
prefix: &str,
|
||||
@@ -847,6 +891,10 @@ fn codex_quota_window_snapshot(
|
||||
.get(&window_minutes_key)
|
||||
.and_then(admin_provider_quota_pure::coerce_json_u64);
|
||||
|
||||
if explicit_window_minutes == Some(0) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if used_percent.is_none()
|
||||
&& reset_at.is_none()
|
||||
&& reset_seconds.is_none()
|
||||
@@ -856,6 +904,7 @@ fn codex_quota_window_snapshot(
|
||||
}
|
||||
|
||||
let window_minutes = explicit_window_minutes.or_else(|| codex_default_window_minutes(code));
|
||||
let (code, label) = codex_quota_window_identity(code, label, window_minutes);
|
||||
let used_ratio = used_percent.map(|value| (value / 100.0).clamp(0.0, 1.0));
|
||||
let remaining_ratio = used_ratio.map(|value| (1.0 - value).max(0.0));
|
||||
|
||||
@@ -931,12 +980,16 @@ fn build_codex_quota_status_snapshot(
|
||||
let primary_windows = windows
|
||||
.iter()
|
||||
.filter(|window| {
|
||||
window
|
||||
let code = window
|
||||
.get("code")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|code| {
|
||||
code.eq_ignore_ascii_case("weekly") || code.eq_ignore_ascii_case("5h")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let scope = window
|
||||
.get("scope")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
scope.eq_ignore_ascii_case("account")
|
||||
&& !code.to_ascii_lowercase().starts_with("spark_")
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
@@ -3792,6 +3845,39 @@ mod tests {
|
||||
assert_eq!(five_h.get("window_minutes"), Some(&json!(300u64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_provider_key_quota_status_snapshot_labels_actual_monthly_window() {
|
||||
let upstream_metadata = json!({
|
||||
"codex": {
|
||||
"updated_at": 1_784_287_450u64,
|
||||
"plan_type": "team",
|
||||
"primary_used_percent": 14.0,
|
||||
"primary_reset_at": 1_786_915_122u64,
|
||||
"primary_window_minutes": 43_800u64,
|
||||
"secondary_used_percent": 0.0,
|
||||
"secondary_reset_after_seconds": 0u64,
|
||||
"secondary_window_minutes": 0u64
|
||||
}
|
||||
});
|
||||
|
||||
let payload = sync_provider_key_quota_status_snapshot(
|
||||
None,
|
||||
"codex",
|
||||
Some(&upstream_metadata),
|
||||
"response_headers",
|
||||
)
|
||||
.expect("quota snapshot should sync");
|
||||
let windows = payload["quota"]["windows"]
|
||||
.as_array()
|
||||
.expect("quota windows should exist");
|
||||
|
||||
assert_eq!(windows.len(), 1);
|
||||
assert_eq!(windows[0]["code"], json!("monthly"));
|
||||
assert_eq!(windows[0]["label"], json!("月"));
|
||||
assert_eq!(windows[0]["window_minutes"], json!(43_800u64));
|
||||
assert_eq!(windows[0]["remaining_ratio"], json!(0.86));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_status_snapshot_payload_backfills_thin_ok_snapshot_from_upstream_metadata() {
|
||||
let mut key = sample_catalog_key();
|
||||
|
||||
@@ -1362,6 +1362,18 @@ async fn gateway_pool_list_overrides_stale_codex_cycle_usage_from_usage_facts()
|
||||
"total_tokens": 700,
|
||||
"total_cost_usd": "0.70000000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"code": "monthly",
|
||||
"label": "月",
|
||||
"scope": "account",
|
||||
"reset_at": reset_at,
|
||||
"window_minutes": 43_800,
|
||||
"usage": {
|
||||
"request_count": 8,
|
||||
"total_tokens": 600,
|
||||
"total_cost_usd": "0.60000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1441,6 +1453,10 @@ async fn gateway_pool_list_overrides_stale_codex_cycle_usage_from_usage_facts()
|
||||
.iter()
|
||||
.find(|window| window["code"] == json!("5h"))
|
||||
.expect("5h window should exist");
|
||||
let monthly = windows
|
||||
.iter()
|
||||
.find(|window| window["code"] == json!("monthly"))
|
||||
.expect("monthly window should exist");
|
||||
|
||||
assert_eq!(weekly["usage"]["request_count"], json!(3));
|
||||
assert_eq!(weekly["usage"]["total_tokens"], json!(2_199));
|
||||
@@ -1448,6 +1464,9 @@ async fn gateway_pool_list_overrides_stale_codex_cycle_usage_from_usage_facts()
|
||||
assert_eq!(five_hour["usage"]["request_count"], json!(1));
|
||||
assert_eq!(five_hour["usage"]["total_tokens"], json!(200));
|
||||
assert_eq!(five_hour["usage"]["total_cost_usd"], json!("0.75000000"));
|
||||
assert_eq!(monthly["usage"]["request_count"], json!(3));
|
||||
assert_eq!(monthly["usage"]["total_tokens"], json!(2_199));
|
||||
assert_eq!(monthly["usage"]["total_cost_usd"], json!("11.99000000"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -630,6 +630,25 @@ fn codex_write_window(
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_window_has_active_limit(source: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
[
|
||||
"window_minutes",
|
||||
"limit_window_seconds",
|
||||
"reset_after_seconds",
|
||||
"reset_at",
|
||||
]
|
||||
.iter()
|
||||
.any(|key| {
|
||||
source
|
||||
.get(*key)
|
||||
.and_then(coerce_json_u64)
|
||||
.is_some_and(|value| value > 0)
|
||||
}) || source
|
||||
.get("used_percent")
|
||||
.and_then(coerce_json_f64)
|
||||
.is_some_and(|value| value > 0.0)
|
||||
}
|
||||
|
||||
fn codex_find_spark_rate_limit(
|
||||
root: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
@@ -719,7 +738,8 @@ pub fn parse_codex_wham_usage_response(
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
let use_paid_windows = !secondary_window.is_empty() && plan_type.as_deref() != Some("free");
|
||||
let use_paid_windows =
|
||||
codex_window_has_active_limit(&secondary_window) && plan_type.as_deref() != Some("free");
|
||||
if use_paid_windows {
|
||||
codex_write_window(&mut result, &secondary_window, "primary");
|
||||
codex_write_window(&mut result, &primary_window, "secondary");
|
||||
@@ -1173,7 +1193,8 @@ pub fn parse_codex_usage_headers(
|
||||
|
||||
let primary_window = read_window("primary");
|
||||
let secondary_window = read_window("secondary");
|
||||
let use_paid_windows = !secondary_window.is_empty() && plan_type.as_deref() != Some("free");
|
||||
let use_paid_windows =
|
||||
codex_window_has_active_limit(&secondary_window) && plan_type.as_deref() != Some("free");
|
||||
if use_paid_windows {
|
||||
codex_write_window(&mut result, &secondary_window, "primary");
|
||||
codex_write_window(&mut result, &primary_window, "secondary");
|
||||
@@ -2091,8 +2112,8 @@ mod tests {
|
||||
codex_build_invalid_state, codex_runtime_invalid_reason,
|
||||
normalize_codex_reset_credit_consume_outcome, parse_antigravity_usage_response,
|
||||
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_codex_usage_headers, 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,
|
||||
provider_auto_remove_quota_exhausted_keys, quota_refresh_success_invalid_state,
|
||||
@@ -2101,6 +2122,7 @@ mod tests {
|
||||
};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn provider_auto_remove_quota_exhausted_keys_defaults_to_false() {
|
||||
@@ -2506,6 +2528,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_codex_monthly_header_without_zero_secondary_placeholder() {
|
||||
let headers = BTreeMap::from([
|
||||
("x-codex-plan-type".to_string(), "team".to_string()),
|
||||
("x-codex-primary-used-percent".to_string(), "14".to_string()),
|
||||
(
|
||||
"x-codex-primary-reset-after-seconds".to_string(),
|
||||
"2627672".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-primary-reset-at".to_string(),
|
||||
"1786915122".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-primary-window-minutes".to_string(),
|
||||
"43800".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-secondary-used-percent".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
(
|
||||
"x-codex-secondary-reset-after-seconds".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
("x-codex-secondary-reset-at".to_string(), "".to_string()),
|
||||
(
|
||||
"x-codex-secondary-window-minutes".to_string(),
|
||||
"0".to_string(),
|
||||
),
|
||||
]);
|
||||
|
||||
let parsed = parse_codex_usage_headers(&headers, 1_784_287_450)
|
||||
.expect("Codex usage headers should parse");
|
||||
|
||||
assert_eq!(parsed.get("primary_used_percent"), Some(&json!(14.0)));
|
||||
assert_eq!(
|
||||
parsed.get("primary_window_minutes"),
|
||||
Some(&json!(43_800u64))
|
||||
);
|
||||
assert!(parsed.get("secondary_used_percent").is_none());
|
||||
assert!(parsed.get("secondary_window_minutes").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_codex_reset_credit_count_from_wham_usage() {
|
||||
let parsed = parse_codex_wham_usage_response(
|
||||
|
||||
@@ -135,10 +135,10 @@
|
||||
:title="row.statusBadgeTitle"
|
||||
>{{ row.statusBadgeLabel }}</Badge>
|
||||
<Badge
|
||||
v-if="row.key.oauth_plan_type"
|
||||
v-if="row.planLabel"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ row.key.oauth_plan_type }}</Badge>
|
||||
>{{ row.planLabel }}</Badge>
|
||||
<Badge
|
||||
v-if="row.oauthOrgBadge"
|
||||
variant="secondary"
|
||||
@@ -499,6 +499,7 @@ import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
|
||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||
import { formatOAuthPlanType } from '@/utils/oauthPlanType'
|
||||
import {
|
||||
canExportOAuthCredential,
|
||||
canRefreshOAuthCredential,
|
||||
@@ -552,6 +553,7 @@ type BatchActionOption = {
|
||||
|
||||
type PageKeyRow = {
|
||||
key: PoolKeyDetail
|
||||
planLabel: string
|
||||
authTypeLabel: string
|
||||
statusBadgeLabel: string | null
|
||||
statusBadgeTitle: string
|
||||
@@ -650,6 +652,7 @@ const pageKeyRows = computed<PageKeyRow[]>(() => pageKeys.value.map((key) => {
|
||||
|
||||
return {
|
||||
key,
|
||||
planLabel: formatOAuthPlanType(key.oauth_plan_type),
|
||||
authTypeLabel: normalizeAuthTypeLabel(key),
|
||||
statusBadgeLabel,
|
||||
statusBadgeTitle: statusBadgeLabel ? getStatusBadgeTitle(key) : '',
|
||||
|
||||
@@ -109,7 +109,10 @@ const QuotaProgressRows = defineComponent({
|
||||
: 'flex flex-col gap-1 min-w-[140px] max-w-[208px]',
|
||||
}, [
|
||||
h('div', { class: 'flex items-center justify-between text-[10px] leading-none' }, [
|
||||
h('span', { class: 'text-muted-foreground font-medium shrink-0' }, item.label),
|
||||
h('span', {
|
||||
'data-testid': 'pool-quota-period-label',
|
||||
class: 'text-muted-foreground font-medium shrink-0',
|
||||
}, item.label),
|
||||
item.resetText
|
||||
? h('span', {
|
||||
'data-testid': 'pool-quota-reset-text',
|
||||
|
||||
@@ -1,41 +1,32 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="cycle"
|
||||
v-if="cycle && cycleMetricRows.length > 0"
|
||||
:class="cycleContainerClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-groups' : undefined"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-text' : 'pool-mobile-stats-cycle-text'"
|
||||
>
|
||||
<div
|
||||
:class="cycleGridClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-grid' : 'pool-mobile-stats-cycle-grid'"
|
||||
v-for="row in cycleMetricRows"
|
||||
:key="`${row.key}-${variant}-cycle-row`"
|
||||
class="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
<span class="shrink-0 text-muted-foreground">{{ row.label }}</span>
|
||||
<span
|
||||
:class="cycleGroupLabelClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-group-5h' : 'pool-mobile-stats-cycle-group-5h'"
|
||||
>5H</span>
|
||||
<span class="text-center text-muted-foreground/50">|</span>
|
||||
<span
|
||||
:class="cycleGroupLabelClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-group-weekly' : 'pool-mobile-stats-cycle-group-weekly'"
|
||||
>{{ legacyT('周') }}</span>
|
||||
|
||||
<template
|
||||
v-for="row in cycleRows"
|
||||
:key="`${row.key}-${variant}-cycle-row`"
|
||||
class="min-w-0 truncate text-right font-medium tabular-nums text-foreground/90"
|
||||
:data-testid="variant === 'desktop' ? `pool-stats-cycle-${row.key}` : undefined"
|
||||
:title="row.valueText"
|
||||
>
|
||||
<span class="text-muted-foreground truncate">{{ row.label }}</span>
|
||||
<span
|
||||
:class="[cycleValueClass, row.fiveH.missing ? 'text-muted-foreground/80' : '']"
|
||||
:data-testid="variant === 'desktop' ? `pool-stats-5h-${row.key}` : undefined"
|
||||
:title="row.fiveH.value"
|
||||
>{{ row.fiveH.value }}</span>
|
||||
<span class="text-center text-muted-foreground/50">|</span>
|
||||
<span
|
||||
:class="[cycleValueClass, row.weekly.missing ? 'text-muted-foreground/80' : '']"
|
||||
:data-testid="variant === 'desktop' ? `pool-stats-weekly-${row.key}` : undefined"
|
||||
:title="row.weekly.value"
|
||||
>{{ row.weekly.value }}</span>
|
||||
</template>
|
||||
{{ row.valueText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="cycle"
|
||||
:class="cycleContainerClass"
|
||||
:data-testid="variant === 'desktop' ? 'pool-stats-cycle-empty' : 'pool-mobile-stats-cycle-empty'"
|
||||
>
|
||||
<div class="flex min-h-16 items-center justify-center text-muted-foreground">
|
||||
—
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,47 +59,67 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { PoolStatsMetric } from '@/features/pool/utils/poolStatsDisplay'
|
||||
|
||||
export interface PoolKeyCycleStatsRow {
|
||||
key: PoolStatsMetric['key']
|
||||
label: string
|
||||
fiveH: PoolStatsMetric
|
||||
weekly: PoolStatsMetric
|
||||
}
|
||||
import type {
|
||||
PoolCodexCycleStatsGroup,
|
||||
PoolStatsMetric,
|
||||
PoolStatsMetricKey,
|
||||
} from '@/features/pool/utils/poolStatsDisplay'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
cycle: boolean
|
||||
cycleRows: PoolKeyCycleStatsRow[]
|
||||
cycleGroups: PoolCodexCycleStatsGroup[]
|
||||
accountMetrics: PoolStatsMetric[]
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}>(), {
|
||||
variant: 'desktop',
|
||||
})
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
const CYCLE_METRIC_KEYS: PoolStatsMetricKey[] = ['request_count', 'total_tokens', 'total_cost_usd']
|
||||
const CYCLE_METRIC_LABELS: Record<PoolStatsMetricKey, string> = {
|
||||
request_count: '请求',
|
||||
total_tokens: 'Token',
|
||||
total_cost_usd: '费用',
|
||||
}
|
||||
|
||||
const cycleContainerClass = computed(() => props.variant === 'desktop'
|
||||
? 'mx-auto w-[188px] text-[10px] leading-4'
|
||||
: ''
|
||||
)
|
||||
function missingMetric(key: PoolStatsMetricKey): PoolStatsMetric {
|
||||
return {
|
||||
key,
|
||||
label: CYCLE_METRIC_LABELS[key],
|
||||
value: '—',
|
||||
missing: true,
|
||||
numericValue: null,
|
||||
}
|
||||
}
|
||||
|
||||
const cycleGridClass = computed(() => [
|
||||
'grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1',
|
||||
props.variant === 'mobile' ? 'text-left' : '',
|
||||
function metricForGroup(
|
||||
group: PoolCodexCycleStatsGroup | undefined,
|
||||
key: PoolStatsMetricKey,
|
||||
): PoolStatsMetric {
|
||||
return group?.metrics.find(metric => metric.key === key) ?? missingMetric(key)
|
||||
}
|
||||
|
||||
const cycleMetricRows = computed(() => {
|
||||
const smallGroup = props.cycleGroups.length > 1 ? props.cycleGroups[0] : undefined
|
||||
const largeGroup = props.cycleGroups.at(-1)
|
||||
if (!largeGroup) return []
|
||||
|
||||
return CYCLE_METRIC_KEYS.map((key) => {
|
||||
const smallMetric = metricForGroup(smallGroup, key)
|
||||
const largeMetric = metricForGroup(largeGroup, key)
|
||||
const hasComparison = Boolean(smallGroup)
|
||||
return {
|
||||
key,
|
||||
label: CYCLE_METRIC_LABELS[key],
|
||||
valueText: hasComparison ? `${smallMetric.value}/${largeMetric.value}` : largeMetric.value,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const cycleContainerClass = computed(() => [
|
||||
'mx-auto w-[132px] space-y-1 text-[9px] leading-3',
|
||||
props.variant === 'mobile' ? 'py-0.5' : '',
|
||||
].filter(Boolean).join(' '))
|
||||
|
||||
const cycleGroupLabelClass = computed(() => props.variant === 'desktop'
|
||||
? 'text-center text-[9px] font-semibold text-muted-foreground/80'
|
||||
: 'text-center text-[10px] font-semibold text-foreground'
|
||||
)
|
||||
|
||||
const cycleValueClass = computed(() => [
|
||||
'min-w-0 truncate text-center text-foreground/90',
|
||||
props.variant === 'desktop' ? 'tabular-nums' : 'font-medium tabular-nums',
|
||||
].join(' '))
|
||||
|
||||
const accountContainerClass = computed(() => props.variant === 'desktop'
|
||||
? 'grid min-h-16 w-[188px] grid-rows-4 gap-0 mx-auto text-[10px] leading-4'
|
||||
: ''
|
||||
|
||||
@@ -11,20 +11,30 @@ describe('pool key display panels', () => {
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(PoolKeyStatsPanel, {
|
||||
cycle: true,
|
||||
cycleRows: [{
|
||||
key: 'request_count',
|
||||
label: '请求',
|
||||
fiveH: { key: 'request_count', label: '请求', value: '12', missing: false },
|
||||
weekly: { key: 'request_count', label: '请求', value: '88', missing: false },
|
||||
}],
|
||||
cycleGroups: [
|
||||
{
|
||||
code: '5h',
|
||||
label: '5H',
|
||||
metrics: [{ key: 'request_count', label: '请求', value: '12', missing: false, numericValue: 12 }],
|
||||
},
|
||||
{
|
||||
code: 'weekly',
|
||||
label: '周',
|
||||
metrics: [{ key: 'request_count', label: '请求', value: '88', missing: false, numericValue: 88 }],
|
||||
},
|
||||
],
|
||||
accountMetrics: [],
|
||||
})
|
||||
app.use(createI18n())
|
||||
app.mount(root)
|
||||
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')).toBeTruthy()
|
||||
expect(root.querySelector('[data-testid="pool-stats-5h-request_count"]')?.textContent).toBe('12')
|
||||
expect(root.querySelector('[data-testid="pool-stats-weekly-request_count"]')?.textContent).toBe('88')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-text"]')).toBeTruthy()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-text"]')?.className).toContain('w-[132px]')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('12/88')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-small-overlay"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-large-base"]')).toBeNull()
|
||||
expect(root.textContent).not.toContain('5H')
|
||||
expect(root.textContent).not.toContain('周')
|
||||
|
||||
app.unmount()
|
||||
root.remove()
|
||||
@@ -68,4 +78,32 @@ describe('pool key display panels', () => {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('renders single-cycle stats as plain text', () => {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(PoolKeyStatsPanel, {
|
||||
cycle: true,
|
||||
cycleGroups: [{
|
||||
code: 'monthly',
|
||||
label: '月',
|
||||
metrics: [
|
||||
{ key: 'request_count', label: '请求', value: '31', missing: false, numericValue: 31 },
|
||||
{ key: 'total_tokens', label: 'Token', value: '38.8K', missing: false, numericValue: 38_800 },
|
||||
{ key: 'total_cost_usd', label: '费用', value: '$0.077', missing: false, numericValue: 0.077 },
|
||||
],
|
||||
}],
|
||||
accountMetrics: [],
|
||||
})
|
||||
app.use(createI18n())
|
||||
app.mount(root)
|
||||
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('31')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-single-marker"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-bar-request_count"]')).toBeNull()
|
||||
expect(root.textContent).not.toContain('月')
|
||||
|
||||
app.unmount()
|
||||
root.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ function createCodexKey(overrides: Partial<PoolStatsKeyInput> = {}): PoolStatsKe
|
||||
windows: [
|
||||
{
|
||||
code: '5h',
|
||||
window_minutes: 300,
|
||||
usage: {
|
||||
request_count: 5,
|
||||
total_tokens: 2500,
|
||||
@@ -27,10 +28,11 @@ function createCodexKey(overrides: Partial<PoolStatsKeyInput> = {}): PoolStatsKe
|
||||
},
|
||||
{
|
||||
code: 'weekly',
|
||||
window_minutes: 10_080,
|
||||
usage: {
|
||||
request_count: 0,
|
||||
total_tokens: 0,
|
||||
total_cost_usd: '0.00000000',
|
||||
request_count: 8,
|
||||
total_tokens: 5000,
|
||||
total_cost_usd: '0.012',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -54,9 +56,9 @@ describe('poolStatsDisplay', () => {
|
||||
total_cost_usd: '$0.0045',
|
||||
})
|
||||
expect(metricValues(display.groups[1].metrics)).toEqual({
|
||||
request_count: '0',
|
||||
total_tokens: '0',
|
||||
total_cost_usd: '0',
|
||||
request_count: '8',
|
||||
total_tokens: '5K',
|
||||
total_cost_usd: '$0.012',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +67,7 @@ describe('poolStatsDisplay', () => {
|
||||
createCodexKey({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
windows: [{ code: '5h', usage: null }],
|
||||
windows: [{ code: '5h', window_minutes: 300, usage: null }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -81,10 +83,48 @@ describe('poolStatsDisplay', () => {
|
||||
total_tokens: '—',
|
||||
total_cost_usd: '—',
|
||||
})
|
||||
expect(metricValues(display.groups[1].metrics)).toEqual({
|
||||
request_count: '—',
|
||||
total_tokens: '—',
|
||||
total_cost_usd: '—',
|
||||
expect(display.groups).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('builds monthly stats from the actual quota window and ignores zero placeholders', () => {
|
||||
const display = buildPoolStatsDisplay(
|
||||
createCodexKey({
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
windows: [
|
||||
{
|
||||
code: 'monthly',
|
||||
label: '月',
|
||||
window_minutes: 43_800,
|
||||
usage: {
|
||||
request_count: 12,
|
||||
total_tokens: 3456,
|
||||
total_cost_usd: '0.125',
|
||||
},
|
||||
},
|
||||
{
|
||||
code: 'weekly',
|
||||
label: '周',
|
||||
window_minutes: 0,
|
||||
usage: {
|
||||
request_count: 99,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
'codex',
|
||||
'current_cycle',
|
||||
)
|
||||
|
||||
expect(display.kind).toBe('codex_cycle')
|
||||
if (display.kind !== 'codex_cycle') throw new Error('expected codex cycle display')
|
||||
expect(display.groups.map(group => group.label)).toEqual(['月'])
|
||||
expect(metricValues(display.groups[0].metrics)).toEqual({
|
||||
request_count: '12',
|
||||
total_tokens: '3.5K',
|
||||
total_cost_usd: '$0.125',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { QuotaWindowUsageSnapshot } from '@/api/endpoints/types/statusSnapshot'
|
||||
import type { PoolManagementStatsMode } from '@/features/pool/utils/poolManagementState'
|
||||
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
|
||||
export type PoolStatsMetricKey = 'request_count' | 'total_tokens' | 'total_cost_usd'
|
||||
export type PoolStatsDisplayKind = 'account_total' | 'codex_cycle'
|
||||
export type PoolCodexCycleWindowCode = '5h' | 'weekly'
|
||||
export type PoolCodexCycleWindowCode = string
|
||||
|
||||
export interface PoolStatsKeyInput {
|
||||
request_count?: number | null
|
||||
@@ -14,6 +15,9 @@ export interface PoolStatsKeyInput {
|
||||
quota?: {
|
||||
windows?: Array<{
|
||||
code?: string | null
|
||||
label?: string | null
|
||||
scope?: string | null
|
||||
window_minutes?: number | null
|
||||
usage?: QuotaWindowUsageSnapshot | null
|
||||
} | null> | null
|
||||
} | null
|
||||
@@ -25,6 +29,7 @@ export interface PoolStatsMetric {
|
||||
label: string
|
||||
value: string
|
||||
missing: boolean
|
||||
numericValue?: number | null
|
||||
}
|
||||
|
||||
export interface PoolAccountTotalStatsDisplay {
|
||||
@@ -46,10 +51,6 @@ export interface PoolCodexCycleStatsDisplay {
|
||||
export type PoolStatsDisplay = PoolAccountTotalStatsDisplay | PoolCodexCycleStatsDisplay
|
||||
|
||||
const MISSING_STAT_VALUE = '—'
|
||||
const CODEX_CYCLE_WINDOWS: Array<{ code: PoolCodexCycleWindowCode, label: string }> = [
|
||||
{ code: '5h', label: '5H' },
|
||||
{ code: 'weekly', label: '周' },
|
||||
]
|
||||
|
||||
export function isCodexProviderType(providerType: string | null | undefined): boolean {
|
||||
return String(providerType || '').trim().toLowerCase() === 'codex'
|
||||
@@ -103,12 +104,14 @@ function createMetric(
|
||||
key: PoolStatsMetricKey,
|
||||
label: string,
|
||||
value: string | null,
|
||||
numericValue?: number | null,
|
||||
): PoolStatsMetric {
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
value: value ?? MISSING_STAT_VALUE,
|
||||
missing: value == null,
|
||||
numericValue: numericValue ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,17 +119,40 @@ function normalizeWindowCode(value: unknown): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function getQuotaWindowUsage(
|
||||
key: PoolStatsKeyInput,
|
||||
code: PoolCodexCycleWindowCode,
|
||||
): QuotaWindowUsageSnapshot | null {
|
||||
function getCodexCycleStatsGroups(key: PoolStatsKeyInput): PoolCodexCycleStatsGroup[] {
|
||||
const windows = key.status_snapshot?.quota?.windows
|
||||
if (!Array.isArray(windows)) return null
|
||||
if (!Array.isArray(windows)) return []
|
||||
|
||||
const window = windows.find(item => normalizeWindowCode(item?.code) === code)
|
||||
return window?.usage ?? null
|
||||
const seenCodes = new Set<string>()
|
||||
return windows
|
||||
.map((window) => {
|
||||
if (!window) return null
|
||||
const code = normalizeWindowCode(window.code)
|
||||
const scope = String(window.scope || 'account').trim().toLowerCase()
|
||||
if (!code || scope !== 'account' || code.startsWith('spark_') || seenCodes.has(code)) {
|
||||
return null
|
||||
}
|
||||
const presentation = getCodexQuotaWindowPresentation({
|
||||
code,
|
||||
label: window.label,
|
||||
scope,
|
||||
window_minutes: window.window_minutes,
|
||||
})
|
||||
if (!presentation) return null
|
||||
seenCodes.add(code)
|
||||
return {
|
||||
code,
|
||||
label: presentation.label,
|
||||
sortOrder: presentation.sortOrder,
|
||||
metrics: buildCycleMetrics(window.usage ?? null),
|
||||
}
|
||||
})
|
||||
.filter((group): group is PoolCodexCycleStatsGroup & { sortOrder: number } => group != null)
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map(({ sortOrder: _sortOrder, ...group }) => group)
|
||||
}
|
||||
|
||||
|
||||
function buildAccountTotalMetrics(key: PoolStatsKeyInput): PoolStatsMetric[] {
|
||||
return [
|
||||
createMetric('request_count', '请求', formatPoolStatInteger(key.request_count)),
|
||||
@@ -136,10 +162,28 @@ function buildAccountTotalMetrics(key: PoolStatsKeyInput): PoolStatsMetric[] {
|
||||
}
|
||||
|
||||
function buildCycleMetrics(usage: QuotaWindowUsageSnapshot | null): PoolStatsMetric[] {
|
||||
const requestCount = usage?.request_count == null ? null : Number(usage.request_count)
|
||||
const totalTokens = usage?.total_tokens == null ? null : Number(usage.total_tokens)
|
||||
const totalCostUsd = usage?.total_cost_usd == null ? null : Number(usage.total_cost_usd)
|
||||
return [
|
||||
createMetric('request_count', '请求', formatCycleInteger(usage?.request_count)),
|
||||
createMetric('total_tokens', 'Token', formatCycleTokenCount(usage?.total_tokens)),
|
||||
createMetric('total_cost_usd', '费用', formatCycleUsd(usage?.total_cost_usd)),
|
||||
createMetric(
|
||||
'request_count',
|
||||
'请求',
|
||||
formatCycleInteger(usage?.request_count),
|
||||
Number.isFinite(requestCount) ? Math.max(requestCount ?? 0, 0) : null,
|
||||
),
|
||||
createMetric(
|
||||
'total_tokens',
|
||||
'Token',
|
||||
formatCycleTokenCount(usage?.total_tokens),
|
||||
Number.isFinite(totalTokens) ? Math.max(totalTokens ?? 0, 0) : null,
|
||||
),
|
||||
createMetric(
|
||||
'total_cost_usd',
|
||||
'费用',
|
||||
formatCycleUsd(usage?.total_cost_usd),
|
||||
Number.isFinite(totalCostUsd) ? Math.max(totalCostUsd ?? 0, 0) : null,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -157,10 +201,7 @@ export function buildCodexCycleStatsDisplay(
|
||||
): PoolCodexCycleStatsDisplay {
|
||||
return {
|
||||
kind: 'codex_cycle',
|
||||
groups: CODEX_CYCLE_WINDOWS.map(window => ({
|
||||
...window,
|
||||
metrics: buildCycleMetrics(getQuotaWindowUsage(key, window.code)),
|
||||
})),
|
||||
groups: getCodexCycleStatsGroups(key),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config/demo', () => ({
|
||||
isDemoMode: () => true,
|
||||
DEMO_ACCOUNTS: {
|
||||
admin: { email: 'admin@demo.aether.io', password: 'demo123' },
|
||||
user: { email: 'user@demo.aether.io', password: 'demo123' },
|
||||
},
|
||||
}))
|
||||
|
||||
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
|
||||
import { handleMockRequest, setMockUserToken } from '../handler'
|
||||
|
||||
interface MockPoolKey {
|
||||
key_id: string
|
||||
oauth_plan_type: string
|
||||
status_snapshot: {
|
||||
quota: {
|
||||
windows: QuotaWindowSnapshot[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('pool quota demo contracts', () => {
|
||||
beforeEach(() => {
|
||||
setMockUserToken('demo-access-token-admin')
|
||||
})
|
||||
|
||||
it('exposes a dedicated Codex pool in the overview and provider summary', async () => {
|
||||
const overviewResponse = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/pool/overview',
|
||||
})
|
||||
const overview = overviewResponse?.data as {
|
||||
items: Array<{ provider_id: string; provider_type: string; total_keys: number }>
|
||||
}
|
||||
const provider = overview.items[0]
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
provider_id: 'provider-codex-pool-demo',
|
||||
provider_type: 'codex',
|
||||
total_keys: 4,
|
||||
})
|
||||
|
||||
const summaryResponse = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: `/api/admin/providers/${provider.provider_id}/summary`,
|
||||
})
|
||||
expect(summaryResponse?.data).toMatchObject({
|
||||
id: provider.provider_id,
|
||||
provider_type: 'codex',
|
||||
name: 'Codex 周期额度演示',
|
||||
})
|
||||
})
|
||||
|
||||
it('covers dual, weekly-only, monthly-only, and 5H-only quota windows', async () => {
|
||||
const response = await handleMockRequest({
|
||||
method: 'GET',
|
||||
url: '/api/admin/pool/provider-codex-pool-demo/keys',
|
||||
params: { page: 1, page_size: 50, status: 'all' },
|
||||
})
|
||||
const page = response?.data as { total: number; keys: MockPoolKey[] }
|
||||
const keys = new Map(page.keys.map(key => [key.key_id, key]))
|
||||
const labelsFor = (keyId: string) => keys.get(keyId)?.status_snapshot.quota.windows
|
||||
.map(getCodexQuotaWindowPresentation)
|
||||
.filter((item): item is NonNullable<typeof item> => item != null)
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
.map(item => item.label)
|
||||
|
||||
expect(page.total).toBe(4)
|
||||
expect(labelsFor('codex-pool-plus-dual')).toEqual(['5H', '周'])
|
||||
expect(labelsFor('codex-pool-team-weekly')).toEqual(['周'])
|
||||
expect(labelsFor('codex-pool-business-monthly')).toEqual(['月'])
|
||||
expect(labelsFor('codex-pool-free-five-hour')).toEqual(['5H'])
|
||||
expect(keys.get('codex-pool-business-monthly')?.oauth_plan_type)
|
||||
.toBe('self_serve_business_usage_based')
|
||||
})
|
||||
})
|
||||
@@ -1091,6 +1091,204 @@ const MOCK_CAPABILITIES = [
|
||||
{ name: 'context_1m', display_name: '1M上下文', description: '支持1M上下文窗口', match_mode: 'compatible', short_name: '1M' }
|
||||
]
|
||||
|
||||
const MOCK_CODEX_POOL_PROVIDER_ID = 'provider-codex-pool-demo'
|
||||
const MOCK_CODEX_POOL_PROVIDER = {
|
||||
id: MOCK_CODEX_POOL_PROVIDER_ID,
|
||||
name: 'Codex 周期额度演示',
|
||||
provider_type: 'codex',
|
||||
description: '展示 5H、周、月及组合额度窗口',
|
||||
website: 'https://openai.com/codex',
|
||||
provider_priority: 0,
|
||||
billing_type: 'free_tier',
|
||||
monthly_used_usd: 0,
|
||||
is_active: true,
|
||||
total_endpoints: 1,
|
||||
active_endpoints: 1,
|
||||
total_keys: 4,
|
||||
active_keys: 4,
|
||||
total_models: 3,
|
||||
active_models: 3,
|
||||
avg_health_score: 0.97,
|
||||
unhealthy_endpoints: 0,
|
||||
api_formats: ['openai:responses'],
|
||||
endpoint_health_details: [
|
||||
{ api_format: 'openai:responses', health_score: 0.97, is_active: true, active_keys: 4 }
|
||||
],
|
||||
pool_advanced: {
|
||||
enabled: true,
|
||||
probing_enabled: true,
|
||||
},
|
||||
claude_code_advanced: null,
|
||||
proxy: null,
|
||||
created_at: '2026-07-01T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
|
||||
function createMockCodexQuotaWindow(
|
||||
code: string,
|
||||
label: string,
|
||||
windowMinutes: number,
|
||||
remainingRatio: number,
|
||||
resetSeconds: number,
|
||||
observedAt: number,
|
||||
requestCount: number,
|
||||
) {
|
||||
return {
|
||||
code,
|
||||
label,
|
||||
scope: 'account',
|
||||
unit: 'percent',
|
||||
used_ratio: 1 - remainingRatio,
|
||||
remaining_ratio: remainingRatio,
|
||||
reset_at: resetSeconds > 0 ? observedAt + resetSeconds : null,
|
||||
reset_seconds: resetSeconds,
|
||||
window_minutes: windowMinutes,
|
||||
usage: {
|
||||
request_count: requestCount,
|
||||
total_tokens: requestCount * 1250,
|
||||
total_cost_usd: (requestCount * 0.0025).toFixed(8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createMockCodexPoolKeys() {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000)
|
||||
const common = {
|
||||
provider_type: 'codex',
|
||||
is_active: true,
|
||||
auth_type: 'oauth',
|
||||
credential_kind: 'oauth_session',
|
||||
runtime_auth_kind: 'bearer',
|
||||
oauth_managed: true,
|
||||
oauth_header_auth: true,
|
||||
can_refresh_oauth: true,
|
||||
can_export_oauth: true,
|
||||
can_edit_oauth: true,
|
||||
oauth_expires_at: nowSeconds + 14 * 24 * 3600,
|
||||
api_formats: ['openai:responses'],
|
||||
rate_multipliers: null,
|
||||
internal_priority: 50,
|
||||
rpm_limit: null,
|
||||
cache_ttl_minutes: 5,
|
||||
max_probe_interval_minutes: 32,
|
||||
health_score: 0.97,
|
||||
circuit_breaker_open: false,
|
||||
proxy: null,
|
||||
cooldown_reason: null,
|
||||
cooldown_ttl_seconds: null,
|
||||
cost_window_usage: 0,
|
||||
cost_limit: null,
|
||||
sticky_sessions: 0,
|
||||
lru_score: null,
|
||||
created_at: '2026-07-01T00:00:00Z',
|
||||
imported_at: '2026-07-01T00:00:00Z',
|
||||
last_used_at: new Date(nowSeconds * 1000 - 10 * 60 * 1000).toISOString(),
|
||||
scheduling_status: 'available',
|
||||
scheduling_reason: 'available',
|
||||
scheduling_label: '可调度',
|
||||
scheduling_reasons: [],
|
||||
}
|
||||
|
||||
const buildKey = (
|
||||
keyId: string,
|
||||
keyName: string,
|
||||
planType: string,
|
||||
accountQuota: string,
|
||||
windows: ReturnType<typeof createMockCodexQuotaWindow>[],
|
||||
requestCount: number,
|
||||
) => ({
|
||||
...common,
|
||||
key_id: keyId,
|
||||
key_name: keyName,
|
||||
oauth_plan_type: planType,
|
||||
oauth_account_id: `acct-${keyId}`,
|
||||
oauth_account_name: keyName,
|
||||
quota_updated_at: nowSeconds - 10 * 60,
|
||||
account_quota: accountQuota,
|
||||
request_count: requestCount,
|
||||
total_tokens: requestCount * 2400,
|
||||
total_cost_usd: (requestCount * 0.004).toFixed(8),
|
||||
status_snapshot: {
|
||||
oauth: {
|
||||
code: 'valid',
|
||||
label: '有效',
|
||||
expires_at: nowSeconds + 14 * 24 * 3600,
|
||||
requires_reauth: false,
|
||||
expiring_soon: false,
|
||||
},
|
||||
account: {
|
||||
code: 'ok',
|
||||
label: null,
|
||||
reason: null,
|
||||
blocked: false,
|
||||
source: null,
|
||||
recoverable: false,
|
||||
},
|
||||
quota: {
|
||||
version: 2,
|
||||
provider_type: 'codex',
|
||||
code: 'ok',
|
||||
label: null,
|
||||
reason: null,
|
||||
freshness: 'fresh',
|
||||
source: 'response_headers',
|
||||
observed_at: nowSeconds,
|
||||
updated_at: nowSeconds,
|
||||
exhausted: false,
|
||||
usage_ratio: windows.reduce((max, window) => Math.max(max, window.used_ratio), 0),
|
||||
plan_type: planType,
|
||||
credits: { has_credits: false, unlimited: false },
|
||||
windows,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return [
|
||||
buildKey(
|
||||
'codex-pool-plus-dual',
|
||||
'Plus · 5H + 周',
|
||||
'plus',
|
||||
'5H剩余 62.0% | 周剩余 84.0%',
|
||||
[
|
||||
createMockCodexQuotaWindow('5h', '5H', 300, 0.62, 3 * 3600, nowSeconds, 18),
|
||||
createMockCodexQuotaWindow('weekly', '周', 10_080, 0.84, 5 * 24 * 3600, nowSeconds, 42),
|
||||
],
|
||||
128,
|
||||
),
|
||||
buildKey(
|
||||
'codex-pool-team-weekly',
|
||||
'Team · 仅周',
|
||||
'team',
|
||||
'周剩余 71.0%',
|
||||
[
|
||||
createMockCodexQuotaWindow('weekly', '周', 10_080, 0.71, 4 * 24 * 3600, nowSeconds, 31),
|
||||
],
|
||||
96,
|
||||
),
|
||||
buildKey(
|
||||
'codex-pool-business-monthly',
|
||||
'Codex · 仅月(含空占位)',
|
||||
'self_serve_business_usage_based',
|
||||
'月剩余 86.0%',
|
||||
[
|
||||
createMockCodexQuotaWindow('monthly', '月', 43_800, 0.86, 2_627_672, nowSeconds, 54),
|
||||
createMockCodexQuotaWindow('weekly', '周', 0, 1, 0, nowSeconds, 0),
|
||||
],
|
||||
214,
|
||||
),
|
||||
buildKey(
|
||||
'codex-pool-free-five-hour',
|
||||
'Free · 仅5H',
|
||||
'free',
|
||||
'5H剩余 93.0%',
|
||||
[
|
||||
createMockCodexQuotaWindow('5h', '5H', 300, 0.93, 4 * 3600, nowSeconds, 7),
|
||||
],
|
||||
37,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock API 路由处理器
|
||||
*/
|
||||
@@ -1513,6 +1711,33 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
|
||||
return createMockResponse(MOCK_PROVIDERS)
|
||||
},
|
||||
|
||||
'GET /api/admin/pool/overview': async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse({
|
||||
items: [{
|
||||
provider_id: MOCK_CODEX_POOL_PROVIDER_ID,
|
||||
provider_name: MOCK_CODEX_POOL_PROVIDER.name,
|
||||
provider_type: 'codex',
|
||||
total_keys: 4,
|
||||
active_keys: 4,
|
||||
cooldown_count: 0,
|
||||
pool_enabled: true,
|
||||
provider_hot_count: 2,
|
||||
provider_desired_hot: 3,
|
||||
provider_in_flight: 1,
|
||||
provider_ema_in_flight: 0.8,
|
||||
provider_burst_pending: false,
|
||||
}]
|
||||
})
|
||||
},
|
||||
|
||||
'GET /api/admin/pool/scheduling-presets': async () => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
return createMockResponse([])
|
||||
},
|
||||
|
||||
'POST /api/admin/providers': async (config) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
@@ -2530,6 +2755,9 @@ registerDynamicRoute('PUT', '/api/admin/modules/status/:moduleName/enabled', asy
|
||||
registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
if (params.providerId === MOCK_CODEX_POOL_PROVIDER_ID) {
|
||||
return createMockResponse(MOCK_CODEX_POOL_PROVIDER)
|
||||
}
|
||||
const provider = MOCK_PROVIDERS.find(p => p.id === params.providerId)
|
||||
if (!provider) {
|
||||
throw { response: createMockResponse({ detail: '提供商不存在' }, 404) }
|
||||
@@ -2537,6 +2765,56 @@ registerDynamicRoute('GET', '/api/admin/providers/:providerId/summary', async (_
|
||||
return createMockResponse(provider)
|
||||
})
|
||||
|
||||
registerDynamicRoute('GET', '/api/admin/pool/:providerId/keys', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
if (params.providerId !== MOCK_CODEX_POOL_PROVIDER_ID) {
|
||||
return createMockResponse({ total: 0, page: 1, page_size: 50, keys: [] })
|
||||
}
|
||||
|
||||
const query = (config.params || {}) as Record<string, unknown>
|
||||
const search = String(query.search || '').trim().toLowerCase()
|
||||
const status = String(query.status || 'all').trim().toLowerCase()
|
||||
const sortBy = String(query.sort_by || 'imported_at').trim()
|
||||
const sortOrder = String(query.sort_order || 'desc').trim().toLowerCase()
|
||||
let keys = createMockCodexPoolKeys()
|
||||
|
||||
if (search) {
|
||||
keys = keys.filter(key => [
|
||||
key.key_name,
|
||||
key.oauth_plan_type,
|
||||
key.oauth_account_id,
|
||||
key.account_quota,
|
||||
].some(value => String(value || '').toLowerCase().includes(search)))
|
||||
}
|
||||
if (status === 'enabled') {
|
||||
keys = keys.filter(key => key.is_active)
|
||||
} else if (status === 'disabled') {
|
||||
keys = keys.filter(key => !key.is_active)
|
||||
} else if (status !== 'all') {
|
||||
keys = keys.filter(key => key.scheduling_status === status || key.scheduling_reason === status)
|
||||
}
|
||||
|
||||
keys.sort((left, right) => {
|
||||
const leftValue = String((left as Record<string, unknown>)[sortBy] ?? left.imported_at ?? '')
|
||||
const rightValue = String((right as Record<string, unknown>)[sortBy] ?? right.imported_at ?? '')
|
||||
const comparison = leftValue.localeCompare(rightValue)
|
||||
return sortOrder === 'asc' ? comparison : -comparison
|
||||
})
|
||||
|
||||
const rawPage = Number(query.page)
|
||||
const rawPageSize = Number(query.page_size)
|
||||
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1
|
||||
const pageSize = Number.isFinite(rawPageSize) && rawPageSize >= 1 ? Math.floor(rawPageSize) : 50
|
||||
const start = (page - 1) * pageSize
|
||||
return createMockResponse({
|
||||
total: keys.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
keys: keys.slice(start, start + pageSize),
|
||||
})
|
||||
})
|
||||
|
||||
// Provider 模型映射预览
|
||||
registerDynamicRoute('GET', '/api/admin/providers/:providerId/mapping-preview', async (_config, params) => {
|
||||
await delay()
|
||||
@@ -2715,6 +2993,25 @@ registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/keys',
|
||||
registerDynamicRoute('POST', '/api/admin/endpoints/providers/:providerId/refresh-quota', async (config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
if (params.providerId === MOCK_CODEX_POOL_PROVIDER_ID) {
|
||||
const body = JSON.parse(config.data || '{}')
|
||||
const requestedKeyIds = Array.isArray(body.key_ids)
|
||||
? body.key_ids.map((id: unknown) => String(id).trim()).filter(Boolean)
|
||||
: createMockCodexPoolKeys().map(key => key.key_id)
|
||||
const keyNames = new Map(createMockCodexPoolKeys().map(key => [key.key_id, key.key_name]))
|
||||
const results = requestedKeyIds.map((keyId: string) => ({
|
||||
key_id: keyId,
|
||||
key_name: keyNames.get(keyId) || keyId,
|
||||
status: 'success',
|
||||
metadata: { updated_at: new Date().toISOString() },
|
||||
}))
|
||||
return createMockResponse({
|
||||
success: results.length,
|
||||
failed: 0,
|
||||
total: results.length,
|
||||
results,
|
||||
})
|
||||
}
|
||||
if (!PROVIDER_KEYS_CACHE[params.providerId]) {
|
||||
PROVIDER_KEYS_CACHE[params.providerId] = generateMockKeysForProvider(params.providerId, 2)
|
||||
}
|
||||
@@ -2854,6 +3151,20 @@ registerDynamicRoute('POST', '/api/admin/endpoints/keys/:keyId/clear-oauth-inval
|
||||
return createMockResponse({ message: 'OAuth invalid cleared (demo)', key_id: params.keyId })
|
||||
})
|
||||
|
||||
registerDynamicRoute('POST', '/api/admin/endpoints/keys/:keyId/reset-cycle-stats', async (_config, params) => {
|
||||
await delay()
|
||||
requireAdmin()
|
||||
const key = createMockCodexPoolKeys().find(item => item.key_id === params.keyId)
|
||||
const windows = key?.status_snapshot.quota.windows.filter(window => (
|
||||
window.window_minutes > 0 && !window.code.startsWith('spark_')
|
||||
)).length ?? 0
|
||||
return createMockResponse({
|
||||
message: '已重置周期统计(演示模式)',
|
||||
reset_at: Math.floor(Date.now() / 1000),
|
||||
windows,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// Keys grouped by format
|
||||
mockHandlers['GET /api/admin/endpoints/keys/grouped-by-format'] = async () => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getCodexQuotaWindowPresentation } from '../codexQuotaWindow'
|
||||
|
||||
describe('getCodexQuotaWindowPresentation', () => {
|
||||
it.each([
|
||||
[300, '5H'],
|
||||
[10_080, '周'],
|
||||
[43_200, '月'],
|
||||
[43_800, '月'],
|
||||
[44_640, '月'],
|
||||
])('labels a %i-minute window as %s', (windowMinutes, expectedLabel) => {
|
||||
expect(getCodexQuotaWindowPresentation({
|
||||
code: 'primary',
|
||||
window_minutes: windowMinutes,
|
||||
})?.label).toBe(expectedLabel)
|
||||
})
|
||||
|
||||
it('supports simultaneous 5H and weekly windows', () => {
|
||||
const windows = [
|
||||
getCodexQuotaWindowPresentation({ code: 'secondary', window_minutes: 10_080 }),
|
||||
getCodexQuotaWindowPresentation({ code: 'primary', window_minutes: 300 }),
|
||||
].filter((item): item is NonNullable<typeof item> => item != null)
|
||||
|
||||
expect(windows.sort((a, b) => a.sortOrder - b.sortOrder).map(item => item.label)).toEqual(['5H', '周'])
|
||||
})
|
||||
|
||||
it('drops zero-minute placeholder windows', () => {
|
||||
expect(getCodexQuotaWindowPresentation({
|
||||
code: 'weekly',
|
||||
label: '周',
|
||||
window_minutes: 0,
|
||||
})).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps legacy labels when old snapshots have no window duration', () => {
|
||||
expect(getCodexQuotaWindowPresentation({ code: '5h' })?.label).toBe('5H')
|
||||
expect(getCodexQuotaWindowPresentation({ code: 'weekly' })?.label).toBe('周')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatOAuthPlanType } from '../oauthPlanType'
|
||||
|
||||
describe('formatOAuthPlanType', () => {
|
||||
it('uses the compact Codex label for the usage-based business plan', () => {
|
||||
expect(formatOAuthPlanType('self_serve_business_usage_based')).toBe('Codex')
|
||||
expect(formatOAuthPlanType(' SELF_SERVE_BUSINESS_USAGE_BASED ')).toBe('Codex')
|
||||
})
|
||||
|
||||
it('keeps existing known plan labels intact', () => {
|
||||
expect(formatOAuthPlanType('plus')).toBe('Plus')
|
||||
expect(formatOAuthPlanType('team')).toBe('Team')
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,34 @@ describe('providerKeyQuota', () => {
|
||||
}, 'codex')).toBe('周剩余 90.0% | 5H剩余 80.0% | Spark5H剩余 60.0% | Spark周剩余 95.0%')
|
||||
})
|
||||
|
||||
it('uses actual Codex window durations and ignores zero placeholders', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
oauth: { code: 'valid' },
|
||||
account: { code: 'ok', blocked: false },
|
||||
quota: {
|
||||
provider_type: 'codex',
|
||||
code: 'ok',
|
||||
exhausted: false,
|
||||
windows: [
|
||||
{
|
||||
code: 'weekly',
|
||||
label: '周',
|
||||
window_minutes: 0,
|
||||
remaining_ratio: 1,
|
||||
},
|
||||
{
|
||||
code: '5h',
|
||||
label: '5H',
|
||||
window_minutes: 43_800,
|
||||
remaining_ratio: 0.86,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}, 'codex')).toBe('月剩余 86.0%')
|
||||
})
|
||||
|
||||
it('formats Grok account quota from structured quota windows', () => {
|
||||
expect(getQuotaDisplayText({
|
||||
status_snapshot: {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
|
||||
|
||||
const MINUTES_PER_HOUR = 60
|
||||
const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR
|
||||
const MINUTES_PER_WEEK = 7 * MINUTES_PER_DAY
|
||||
const MIN_MONTH_MINUTES = 28 * MINUTES_PER_DAY
|
||||
const MAX_MONTH_MINUTES = 31 * MINUTES_PER_DAY
|
||||
|
||||
export interface CodexQuotaWindowPresentation {
|
||||
label: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
function formatCodexQuotaPeriod(windowMinutes: number): string {
|
||||
if (windowMinutes === 5 * MINUTES_PER_HOUR) return '5H'
|
||||
if (windowMinutes === MINUTES_PER_WEEK) return '周'
|
||||
if (windowMinutes >= MIN_MONTH_MINUTES && windowMinutes <= MAX_MONTH_MINUTES) return '月'
|
||||
|
||||
if (windowMinutes % MINUTES_PER_WEEK === 0) {
|
||||
return `${windowMinutes / MINUTES_PER_WEEK}周`
|
||||
}
|
||||
if (windowMinutes % MINUTES_PER_DAY === 0) {
|
||||
return `${windowMinutes / MINUTES_PER_DAY}天`
|
||||
}
|
||||
if (windowMinutes % MINUTES_PER_HOUR === 0) {
|
||||
return `${windowMinutes / MINUTES_PER_HOUR}H`
|
||||
}
|
||||
return `${windowMinutes}分钟`
|
||||
}
|
||||
|
||||
function getLegacyCodexQuotaPeriod(code: string, label: string): string | null {
|
||||
if (code === '5h') return '5H'
|
||||
if (code === 'weekly') return '周'
|
||||
if (code === 'monthly') return '月'
|
||||
return label || null
|
||||
}
|
||||
|
||||
export function getCodexQuotaWindowPresentation(
|
||||
window: QuotaWindowSnapshot,
|
||||
): CodexQuotaWindowPresentation | null {
|
||||
const code = String(window.code || '').trim().toLowerCase()
|
||||
const isSpark = code.startsWith('spark_')
|
||||
const baseCode = isSpark ? code.slice('spark_'.length) : code
|
||||
const rawLabel = String(window.label || '').trim().replace(/^Spark\s*/i, '')
|
||||
const hasExplicitWindowMinutes = window.window_minutes != null
|
||||
const windowMinutes = Number(window.window_minutes)
|
||||
|
||||
if (hasExplicitWindowMinutes && (!Number.isFinite(windowMinutes) || windowMinutes <= 0)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const period = hasExplicitWindowMinutes
|
||||
? formatCodexQuotaPeriod(windowMinutes)
|
||||
: getLegacyCodexQuotaPeriod(baseCode, rawLabel)
|
||||
if (!period) return null
|
||||
|
||||
const fallbackOrder = baseCode === '5h' ? 300 : baseCode === 'weekly' ? 10_080 : 1_000_000
|
||||
return {
|
||||
label: isSpark ? `Spark${period}` : period,
|
||||
sortOrder: (isSpark ? 10_000_000 : 0) + (hasExplicitWindowMinutes ? windowMinutes : fallbackOrder),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const PLAN_TYPE_LABELS: Record<string, string> = {
|
||||
self_serve_business_usage_based: 'Codex',
|
||||
free: 'Free',
|
||||
plus: 'Plus',
|
||||
team: 'Team',
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
QuotaWindowSnapshot,
|
||||
} from '@/api/endpoints/types/statusSnapshot'
|
||||
import type { UpstreamMetadata } from '@/api/endpoints/types/provider'
|
||||
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
|
||||
|
||||
export interface ProviderKeyQuotaCarrier {
|
||||
account_quota?: string | null
|
||||
@@ -222,15 +223,11 @@ function getGrokQuotaWindowLabel(window: QuotaWindowSnapshot): string {
|
||||
|
||||
function getCodexQuotaText(quota: QuotaStatusSnapshot): string | null {
|
||||
const parts: string[] = []
|
||||
for (const [label, code] of [
|
||||
['周', 'weekly'],
|
||||
['5H', '5h'],
|
||||
['Spark5H', 'spark_5h'],
|
||||
['Spark周', 'spark_weekly'],
|
||||
] as const) {
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(getQuotaWindow(quota, code))
|
||||
if (remainingPercent == null) continue
|
||||
parts.push(`${label}剩余 ${formatPercent(remainingPercent)}`)
|
||||
for (const window of getQuotaWindows(quota)) {
|
||||
const presentation = getCodexQuotaWindowPresentation(window)
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (!presentation || remainingPercent == null) continue
|
||||
parts.push(`${presentation.label}剩余 ${formatPercent(remainingPercent)}`)
|
||||
}
|
||||
if (parts.length > 0) return parts.join(' | ')
|
||||
|
||||
|
||||
@@ -110,21 +110,7 @@
|
||||
class="px-2 font-semibold text-center whitespace-nowrap"
|
||||
:style="{ width: desktopColumnWidths.stats }"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-1.5">
|
||||
<button
|
||||
v-if="showCodexStatsModeToggle"
|
||||
type="button"
|
||||
class="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
|
||||
:title="poolStatsMode === 'current_cycle' ? '切换为总计统计' : '切换为周期统计'"
|
||||
:aria-label="poolStatsMode === 'current_cycle' ? '切换为总计统计' : '切换为周期统计'"
|
||||
:aria-pressed="poolStatsMode === 'current_cycle'"
|
||||
data-testid="pool-stats-mode-control"
|
||||
@click.stop="togglePoolStatsMode"
|
||||
>
|
||||
<Repeat2 class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span>统计</span>
|
||||
</div>
|
||||
<span>统计</span>
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
class="font-semibold text-center whitespace-nowrap"
|
||||
@@ -314,7 +300,7 @@
|
||||
<TableCell class="py-3 px-2 align-middle">
|
||||
<PoolKeyStatsPanel
|
||||
:cycle="isPoolKeyCycleStatsDisplay(key)"
|
||||
:cycle-rows="getPoolKeyCycleStatsRows(key)"
|
||||
:cycle-groups="getPoolKeyCycleStatsGroups(key)"
|
||||
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -592,7 +578,7 @@
|
||||
<div class="space-y-1 text-center">
|
||||
<PoolKeyStatsPanel
|
||||
:cycle="isPoolKeyCycleStatsDisplay(key)"
|
||||
:cycle-rows="getPoolKeyCycleStatsRows(key)"
|
||||
:cycle-groups="getPoolKeyCycleStatsGroups(key)"
|
||||
:account-metrics="getPoolKeyAccountStatsMetrics(key)"
|
||||
variant="mobile"
|
||||
/>
|
||||
@@ -977,7 +963,6 @@ import {
|
||||
Copy,
|
||||
Shield,
|
||||
Globe,
|
||||
Repeat2,
|
||||
RotateCcw,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
@@ -1065,7 +1050,6 @@ import {
|
||||
resolvePoolManagementPageAfterLoad,
|
||||
type PoolManagementSortBy,
|
||||
type PoolManagementSortOrder,
|
||||
type PoolManagementStatsMode,
|
||||
type PoolManagementViewState,
|
||||
writePoolManagementViewState,
|
||||
} from '@/features/pool/utils/poolManagementState'
|
||||
@@ -1075,7 +1059,9 @@ import {
|
||||
type PoolStatsDisplay,
|
||||
type PoolStatsMetric,
|
||||
} from '@/features/pool/utils/poolStatsDisplay'
|
||||
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
|
||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
|
||||
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
||||
import {
|
||||
canEditOAuthCredential,
|
||||
@@ -1119,7 +1105,6 @@ const restoredViewState = readPoolManagementViewState(
|
||||
pageSize: getQueryValue('pageSize'),
|
||||
sortBy: getQueryValue('sortBy'),
|
||||
sortOrder: getQueryValue('sortOrder'),
|
||||
statsMode: getQueryValue('statsMode'),
|
||||
},
|
||||
poolManagementViewStorage,
|
||||
)
|
||||
@@ -1487,8 +1472,6 @@ const selectedProviderType = computed(() => {
|
||||
return String(fromOverview || '').trim().toLowerCase()
|
||||
})
|
||||
|
||||
const showCodexStatsModeToggle = computed(() => selectedProviderType.value === 'codex')
|
||||
|
||||
const selectedProviderStatusText = computed(() => {
|
||||
if (!selectedProviderId.value) return ''
|
||||
const providerActive = selectedProviderData.value?.is_active
|
||||
@@ -1673,7 +1656,6 @@ const currentPage = ref(restoredViewState.page)
|
||||
const pageSize = ref(restoredViewState.pageSize)
|
||||
const sortBy = ref<PoolManagementSortBy | null>(restoredViewState.sortBy)
|
||||
const sortOrder = ref<PoolManagementSortOrder>(restoredViewState.sortOrder)
|
||||
const poolStatsMode = ref<PoolManagementStatsMode>(restoredViewState.statsMode)
|
||||
const hasPoolKeyFilters = computed(() => searchQuery.value.trim().length > 0 || statusFilter.value !== 'all')
|
||||
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
|
||||
const refreshingOAuthKeyId = ref<string | null>(null)
|
||||
@@ -1694,12 +1676,6 @@ const keyFormDialogOpen = ref(false)
|
||||
const oauthKeyEditDialogOpen = ref(false)
|
||||
const editingKeyDetail = ref<PoolKeyDetail | null>(null)
|
||||
|
||||
function togglePoolStatsMode() {
|
||||
poolStatsMode.value = poolStatsMode.value === 'current_cycle'
|
||||
? 'account_total'
|
||||
: 'current_cycle'
|
||||
}
|
||||
|
||||
function clearPoolKeyFilters() {
|
||||
if (!hasPoolKeyFilters.value) return
|
||||
suppressFiltersWatch = true
|
||||
@@ -1764,18 +1740,6 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => readPoolManagementViewState(
|
||||
{ statsMode: getQueryValue('statsMode') },
|
||||
poolManagementViewStorage,
|
||||
).statsMode,
|
||||
(value) => {
|
||||
if (poolStatsMode.value === value) return
|
||||
poolStatsMode.value = value
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => getQueryValue('providerId'),
|
||||
(value) => {
|
||||
@@ -1792,8 +1756,8 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
[selectedProviderId, searchQuery, statusFilter, currentPage, pageSize, sortBy, sortOrder, poolStatsMode],
|
||||
([providerId, search, status, page, pageSizeValue, sortByValue, sortOrderValue, statsMode]) => {
|
||||
[selectedProviderId, searchQuery, statusFilter, currentPage, pageSize, sortBy, sortOrder],
|
||||
([providerId, search, status, page, pageSizeValue, sortByValue, sortOrderValue]) => {
|
||||
const nextState: PoolManagementViewState = {
|
||||
providerId,
|
||||
search,
|
||||
@@ -1802,7 +1766,7 @@ watch(
|
||||
pageSize: pageSizeValue,
|
||||
sortBy: sortByValue,
|
||||
sortOrder: sortOrderValue,
|
||||
statsMode: statsMode as PoolManagementStatsMode,
|
||||
statsMode: 'current_cycle',
|
||||
}
|
||||
patchQuery(buildPoolManagementQueryPatch(nextState))
|
||||
writePoolManagementViewState(nextState, poolManagementViewStorage)
|
||||
@@ -1812,6 +1776,7 @@ watch(
|
||||
interface QuotaProgressItem {
|
||||
label: string
|
||||
remainingPercent: number
|
||||
sortOrder?: number
|
||||
detail?: string
|
||||
resetAtSeconds?: number | null
|
||||
resetSeconds?: number | null
|
||||
@@ -1828,20 +1793,6 @@ interface QuotaProgressDisplayItem {
|
||||
meterClass: string
|
||||
}
|
||||
|
||||
interface PoolCodexCycleStatsRow {
|
||||
key: PoolStatsMetric['key']
|
||||
label: string
|
||||
fiveH: PoolStatsMetric
|
||||
weekly: PoolStatsMetric
|
||||
}
|
||||
|
||||
const CODEX_CYCLE_STAT_KEYS: Array<PoolStatsMetric['key']> = ['request_count', 'total_tokens', 'total_cost_usd']
|
||||
const CODEX_CYCLE_STAT_LABELS: Record<PoolStatsMetric['key'], string> = {
|
||||
request_count: '请求',
|
||||
total_tokens: 'Token',
|
||||
total_cost_usd: '费用',
|
||||
}
|
||||
|
||||
type PoolKeyUiState = {
|
||||
rowClass: string
|
||||
schedulingBadgeLabel: string
|
||||
@@ -1920,7 +1871,7 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
|
||||
: '',
|
||||
importedAtRelative: formatPoolKeyImportedAt(key),
|
||||
lastUsedRelative: key.last_used_at ? formatRelativeTime(key.last_used_at) : '-',
|
||||
statsDisplay: buildPoolStatsDisplay(key, selectedProviderType.value, poolStatsMode.value),
|
||||
statsDisplay: buildPoolStatsDisplay(key, selectedProviderType.value, 'current_cycle'),
|
||||
mobileTagItems: getMobileTagItems(key),
|
||||
mobileActionIds: splitPoolMobileActions({
|
||||
canDownloadOrCopy: true,
|
||||
@@ -1937,7 +1888,7 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
|
||||
|
||||
function getPoolKeyStatsDisplay(key: PoolKeyDetail): PoolStatsDisplay {
|
||||
return keyUiStateMap.value[key.key_id]?.statsDisplay
|
||||
?? buildPoolStatsDisplay(key, selectedProviderType.value, poolStatsMode.value)
|
||||
?? buildPoolStatsDisplay(key, selectedProviderType.value, 'current_cycle')
|
||||
}
|
||||
|
||||
function isPoolKeyCycleStatsDisplay(key: PoolKeyDetail): boolean {
|
||||
@@ -1949,39 +1900,6 @@ function getPoolKeyCycleStatsGroups(key: PoolKeyDetail): PoolCodexCycleStatsGrou
|
||||
return display.kind === 'codex_cycle' ? display.groups : []
|
||||
}
|
||||
|
||||
function createMissingCycleMetric(key: PoolStatsMetric['key']): PoolStatsMetric {
|
||||
return {
|
||||
key,
|
||||
label: CODEX_CYCLE_STAT_LABELS[key],
|
||||
value: '—',
|
||||
missing: true,
|
||||
}
|
||||
}
|
||||
|
||||
function findCycleMetric(
|
||||
group: PoolCodexCycleStatsGroup | undefined,
|
||||
key: PoolStatsMetric['key'],
|
||||
): PoolStatsMetric {
|
||||
return group?.metrics.find(metric => metric.key === key) ?? createMissingCycleMetric(key)
|
||||
}
|
||||
|
||||
function getPoolKeyCycleStatsRows(key: PoolKeyDetail): PoolCodexCycleStatsRow[] {
|
||||
const groups = getPoolKeyCycleStatsGroups(key)
|
||||
const fiveHGroup = groups.find(group => group.code === '5h')
|
||||
const weeklyGroup = groups.find(group => group.code === 'weekly')
|
||||
|
||||
return CODEX_CYCLE_STAT_KEYS.map((metricKey) => {
|
||||
const fiveH = findCycleMetric(fiveHGroup, metricKey)
|
||||
const weekly = findCycleMetric(weeklyGroup, metricKey)
|
||||
return {
|
||||
key: metricKey,
|
||||
label: CODEX_CYCLE_STAT_LABELS[metricKey],
|
||||
fiveH,
|
||||
weekly,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
|
||||
const display = getPoolKeyStatsDisplay(key)
|
||||
return display.kind === 'account_total'
|
||||
@@ -3110,42 +3028,6 @@ function getMobileTagClass(item: PoolMobileTagItem): string {
|
||||
return 'border-border/60 bg-background/80 text-foreground/80'
|
||||
}
|
||||
|
||||
function formatOAuthPlanType(planType: string): string {
|
||||
const labelMap: Record<string, string> = {
|
||||
plus: 'Plus',
|
||||
pro: 'Pro',
|
||||
free: 'Free',
|
||||
paid: 'Paid',
|
||||
team: 'Team',
|
||||
enterprise: 'Enterprise',
|
||||
ultra: 'Ultra',
|
||||
'pro+': 'Pro+',
|
||||
power: 'Power',
|
||||
basic: 'Basic',
|
||||
super: 'Super',
|
||||
heavy: 'Heavy',
|
||||
}
|
||||
return labelMap[planType.toLowerCase()] || planType
|
||||
}
|
||||
|
||||
function getOAuthPlanTypeClass(planType: string): string {
|
||||
const classes: Record<string, string> = {
|
||||
plus: 'border-green-500/50 text-green-600 dark:text-green-400',
|
||||
pro: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
|
||||
free: 'border-primary/50 text-primary',
|
||||
paid: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
|
||||
team: 'border-purple-500/50 text-purple-600 dark:text-purple-400',
|
||||
enterprise: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
||||
ultra: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
||||
'pro+': 'border-purple-500/50 text-purple-600 dark:text-purple-400',
|
||||
power: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
||||
basic: 'border-primary/50 text-primary',
|
||||
super: 'border-green-500/50 text-green-600 dark:text-green-400',
|
||||
heavy: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
||||
}
|
||||
return classes[planType.toLowerCase()] || ''
|
||||
}
|
||||
|
||||
function getVisibleOAuthState(key: PoolKeyDetail) {
|
||||
return getOAuthStatusDisplayWithFallback(key, countdownTick.value)
|
||||
}
|
||||
@@ -3233,6 +3115,7 @@ function getQuotaProgressLabel(label: string): string {
|
||||
if (label === '日') return '日'
|
||||
if (label === '5H') return '5H'
|
||||
if (label === '周') return '周'
|
||||
if (label === '月') return '月'
|
||||
if (label === 'Spark5H') return 'Spark5H'
|
||||
if (label === 'Spark周') return 'Spark周'
|
||||
if (label === '最低') return '最低'
|
||||
@@ -3241,7 +3124,7 @@ function getQuotaProgressLabel(label: string): string {
|
||||
}
|
||||
|
||||
function getQuotaProgressCountdown(item: QuotaProgressItem) {
|
||||
const staticResetLabels = ['日', '5H', '周', 'Spark5H', 'Spark周', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
|
||||
const staticResetLabels = ['日', '5H', '周', '月', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图']
|
||||
if (!item.allowDynamicReset && !staticResetLabels.includes(item.label)) return null
|
||||
if (item.resetAtSeconds == null && item.resetSeconds == null) return null
|
||||
return getCodexResetCountdown(
|
||||
@@ -3303,15 +3186,17 @@ function getQuotaLabelOrder(label: string): number {
|
||||
if (label === '日') return 5
|
||||
if (label === '5H') return 6
|
||||
if (label === '周') return 7
|
||||
if (label === 'Spark5H') return 8
|
||||
if (label === 'Spark周') return 9
|
||||
if (label === 'Prompt') return 10
|
||||
if (label === 'Flex') return 11
|
||||
if (label === '剩余') return 12
|
||||
if (label === '最低') return 13
|
||||
if (label === '生图') return 14
|
||||
if (label === '速率') return 15
|
||||
if (label === '模型') return 16
|
||||
if (label === '月') return 8
|
||||
if (label === 'Spark5H') return 9
|
||||
if (label === 'Spark周') return 10
|
||||
if (label === 'Spark月') return 11
|
||||
if (label === 'Prompt') return 12
|
||||
if (label === 'Flex') return 13
|
||||
if (label === '剩余') return 14
|
||||
if (label === '最低') return 15
|
||||
if (label === '生图') return 16
|
||||
if (label === '速率') return 17
|
||||
if (label === '模型') return 18
|
||||
return 20
|
||||
}
|
||||
|
||||
@@ -3479,27 +3364,24 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI
|
||||
const providerType = getQuotaSnapshotProviderType(key)
|
||||
|
||||
if (providerType === 'codex') {
|
||||
const items: QuotaProgressItem[] = []
|
||||
const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota)
|
||||
const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota)
|
||||
for (const [label, code] of [
|
||||
['5H', '5h'],
|
||||
['周', 'weekly'],
|
||||
['Spark5H', 'spark_5h'],
|
||||
['Spark周', 'spark_weekly'],
|
||||
] as const) {
|
||||
const window = getQuotaSnapshotWindow(quota, code)
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (remainingPercent == null) continue
|
||||
items.push({
|
||||
label,
|
||||
remainingPercent,
|
||||
resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
return (quota.windows ?? [])
|
||||
.map((window): QuotaProgressItem | null => {
|
||||
const presentation = getCodexQuotaWindowPresentation(window)
|
||||
const remainingPercent = getQuotaWindowRemainingPercent(window)
|
||||
if (!presentation || remainingPercent == null) return null
|
||||
return {
|
||||
label: presentation.label,
|
||||
sortOrder: presentation.sortOrder,
|
||||
remainingPercent,
|
||||
resetAtSeconds: normalizeUnixSeconds(window.reset_at ?? quotaResetAtSeconds ?? null),
|
||||
resetSeconds: normalizeRemainingSeconds(window.reset_seconds ?? quotaResetSeconds ?? null),
|
||||
updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota),
|
||||
allowDynamicReset: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
return items
|
||||
.filter((item): item is QuotaProgressItem => item != null)
|
||||
}
|
||||
|
||||
if (providerType === 'kiro') {
|
||||
@@ -3739,7 +3621,7 @@ function parseQuotaProgressItems(key: PoolKeyDetail): QuotaProgressItem[] {
|
||||
const snapshotItems = buildQuotaProgressItemsFromSnapshot(key)
|
||||
if (snapshotItems.length > 0) {
|
||||
return snapshotItems.sort((a, b) => {
|
||||
const orderDiff = getQuotaLabelOrder(a.label) - getQuotaLabelOrder(b.label)
|
||||
const orderDiff = (a.sortOrder ?? getQuotaLabelOrder(a.label)) - (b.sortOrder ?? getQuotaLabelOrder(b.label))
|
||||
if (orderDiff !== 0) return orderDiff
|
||||
return a.label.localeCompare(b.label, 'zh-Hans-CN')
|
||||
})
|
||||
|
||||
@@ -133,7 +133,6 @@ vi.mock('lucide-vue-next', async () => {
|
||||
Copy: Icon,
|
||||
Shield: Icon,
|
||||
Globe: Icon,
|
||||
Repeat2: Icon,
|
||||
RotateCcw: Icon,
|
||||
SquarePen: Icon,
|
||||
Trash2: Icon,
|
||||
@@ -492,7 +491,7 @@ function createPoolKey(providerType = 'codex', overrides: Partial<PoolKeyDetail>
|
||||
{
|
||||
code: 'weekly',
|
||||
remaining_ratio: 0.5,
|
||||
usage: { request_count: 0, total_tokens: 0, total_cost_usd: '0.00000000' },
|
||||
usage: { request_count: 12, total_tokens: 5000, total_cost_usd: '0.012' },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -533,13 +532,6 @@ async function settle() {
|
||||
}
|
||||
}
|
||||
|
||||
function seedStoredStatsMode(statsMode: 'current_cycle' | 'account_total') {
|
||||
window.sessionStorage.setItem(
|
||||
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
|
||||
JSON.stringify({ statsMode }),
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetQuery()
|
||||
window.sessionStorage.clear()
|
||||
@@ -574,7 +566,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('PoolManagement Codex cycle stats mode', () => {
|
||||
it('renders Codex current-cycle stats by default with a header icon toggle', async () => {
|
||||
it('renders current-cycle comparison text without a mode toggle', async () => {
|
||||
const codexKey = createPoolKey('codex')
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey))
|
||||
@@ -583,19 +575,12 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
expect(root.querySelector('[data-testid="pool-stats-mode-switch"]')).toBeNull()
|
||||
const modeButton = root.querySelector<HTMLButtonElement>('[data-testid="pool-stats-mode-control"]')
|
||||
expect(modeButton).not.toBeNull()
|
||||
expect(modeButton?.getAttribute('title')).toBe('切换为总计统计')
|
||||
expect(root.querySelectorAll('[data-testid="pool-stats-cycle-group-5h"]').length).toBeGreaterThan(0)
|
||||
expect(root.querySelectorAll('[data-testid="pool-stats-cycle-group-weekly"]').length).toBeGreaterThan(0)
|
||||
expect(root.querySelector('[data-testid="pool-stats-5h-request_count"]')?.textContent?.trim()).toBe('7')
|
||||
expect(root.querySelector('[data-testid="pool-stats-weekly-total_tokens"]')?.textContent?.trim()).toBe('0')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')?.className).toContain('grid-cols-[38px_64px_10px_64px]')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')?.className).toContain('w-[188px]')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-grid"]')?.className).toContain('min-h-16')
|
||||
expect(root.querySelector('[data-testid="pool-stats-5h-request_count"]')?.className).toContain('text-center')
|
||||
expect(root.querySelector('[data-testid="pool-stats-weekly-total_tokens"]')?.className).toContain('text-center')
|
||||
expect(root.querySelector('[data-testid="pool-stats-mode-control"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-text"]')).not.toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('7/12')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-total_tokens"]')?.textContent?.trim()).toBe('2.5K/5K')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-small-overlay"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-large-base"]')).toBeNull()
|
||||
expect(endpointMocks.listPoolKeys).toHaveBeenLastCalledWith(
|
||||
'codex-provider',
|
||||
expect.objectContaining({
|
||||
@@ -684,6 +669,50 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
||||
expect(root.textContent).toContain('生图')
|
||||
})
|
||||
|
||||
it('labels Codex quota by the actual refresh window duration', async () => {
|
||||
const monthlyCodexKey = createPoolKey('codex', {
|
||||
status_snapshot: {
|
||||
oauth: { code: 'valid' },
|
||||
account: { code: 'ok', blocked: false },
|
||||
quota: {
|
||||
code: 'ok',
|
||||
exhausted: false,
|
||||
provider_type: 'codex',
|
||||
windows: [
|
||||
{
|
||||
code: 'weekly',
|
||||
remaining_ratio: 0.86,
|
||||
window_minutes: 43_800,
|
||||
usage: { request_count: 23, total_tokens: 45_600, total_cost_usd: '0.1234' },
|
||||
},
|
||||
{
|
||||
code: '5h',
|
||||
remaining_ratio: 1,
|
||||
window_minutes: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(monthlyCodexKey))
|
||||
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
|
||||
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
const periodLabels = Array.from(root.querySelectorAll('[data-testid="pool-quota-period-label"]'))
|
||||
.map((element) => element.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
expect(periodLabels).toContain('月')
|
||||
expect(periodLabels).not.toContain('5H')
|
||||
expect(periodLabels).not.toContain('周')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('23')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-small-overlay"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-bar-request_count"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-single-marker"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens only one score popover across desktop and mobile layouts', async () => {
|
||||
const scoredKey = createPoolKey('codex', {
|
||||
pool_score: {
|
||||
@@ -768,32 +797,11 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
||||
expect(endpointMocks.refreshProviderQuota).not.toHaveBeenCalledWith('codex-provider')
|
||||
})
|
||||
|
||||
it('toggles Codex stats to account totals and persists the choice', async () => {
|
||||
const codexKey = createPoolKey('codex')
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey))
|
||||
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
|
||||
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
const modeButton = root.querySelector<HTMLButtonElement>('[data-testid="pool-stats-mode-control"]')
|
||||
expect(modeButton).not.toBeNull()
|
||||
modeButton?.click()
|
||||
await settle()
|
||||
|
||||
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-account-total"]')?.className).toContain('w-[188px]')
|
||||
expect(root.querySelector('[data-testid="pool-stats-account-total"]')?.className).toContain('grid-rows-4')
|
||||
expect(root.querySelector('[data-testid="pool-stats-account-total"]')?.className).toContain('min-h-16')
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-group-5h"]')).toBeNull()
|
||||
expect(routeMocks.query.statsMode).toBe('account_total')
|
||||
expect(window.sessionStorage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)).toContain('"statsMode":"account_total"')
|
||||
expect(modeButton?.getAttribute('title')).toBe('切换为周期统计')
|
||||
})
|
||||
|
||||
it('restores stored and query account-total mode for Codex providers', async () => {
|
||||
seedStoredStatsMode('account_total')
|
||||
it('ignores legacy account-total mode and removes it from the route', async () => {
|
||||
window.sessionStorage.setItem(
|
||||
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
|
||||
JSON.stringify({ statsMode: 'account_total' }),
|
||||
)
|
||||
routeMocks.query.statsMode = 'account_total'
|
||||
const codexKey = createPoolKey('codex')
|
||||
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
|
||||
@@ -803,10 +811,10 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-group-5h"]')).toBeNull()
|
||||
expect(routeMocks.query.statsMode).toBe('account_total')
|
||||
expect(window.sessionStorage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)).toContain('"statsMode":"account_total"')
|
||||
expect(root.querySelector('[data-testid="pool-stats-mode-control"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-text"]')).not.toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).toBeNull()
|
||||
expect(routeMocks.query.statsMode).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets Codex cycle stats from the action column', async () => {
|
||||
@@ -841,10 +849,9 @@ describe('PoolManagement Codex cycle stats mode', () => {
|
||||
const root = mountPoolManagement()
|
||||
await settle()
|
||||
|
||||
expect(root.querySelector('[data-testid="pool-stats-mode-switch"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-mode-control"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-reset-cycle-stats"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-group-5h"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-cycle-text"]')).toBeNull()
|
||||
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull()
|
||||
expect(root.textContent).toContain('12')
|
||||
expect(root.textContent).toContain('3.5K')
|
||||
|
||||
Reference in New Issue
Block a user