Add provider key cycle stats reset handling

This commit is contained in:
fawney19
2026-05-07 03:23:20 +08:00
parent 6c0ac2e8da
commit 10e679bb2f
35 changed files with 1443 additions and 273 deletions

View File

@@ -155,6 +155,17 @@ pub(super) fn classify_admin_endpoints_family_route(
"admin:endpoints_manage", "admin:endpoints_manage",
false, false,
)) ))
} else if method == http::Method::POST
&& normalized_path.starts_with("/api/admin/endpoints/keys/")
&& normalized_path.ends_with("/reset-cycle-stats")
{
Some(classified(
"admin_proxy",
"endpoints_manage",
"reset_cycle_stats",
"admin:endpoints_manage",
false,
))
} else if method == http::Method::POST } else if method == http::Method::POST
&& normalized_path.starts_with("/api/admin/endpoints/providers/") && normalized_path.starts_with("/api/admin/endpoints/providers/")
&& normalized_path.ends_with("/refresh-quota") && normalized_path.ends_with("/refresh-quota")

View File

@@ -275,6 +275,20 @@ fn classifies_admin_clear_oauth_invalid_as_admin_proxy_route() {
assert!(!decision.is_execution_runtime_candidate()); assert!(!decision.is_execution_runtime_candidate());
} }
#[test]
fn classifies_admin_reset_key_cycle_stats_as_admin_proxy_route() {
let headers = http::HeaderMap::new();
let uri: Uri = "/api/admin/endpoints/keys/key-codex/reset-cycle-stats"
.parse()
.expect("uri should parse");
let decision = classify_control_route(&http::Method::POST, &uri, &headers)
.expect("decision should resolve");
assert_eq!(decision.route_class.as_deref(), Some("admin_proxy"));
assert_eq!(decision.route_family.as_deref(), Some("endpoints_manage"));
assert_eq!(decision.route_kind.as_deref(), Some("reset_cycle_stats"));
assert!(!decision.is_execution_runtime_candidate());
}
#[test] #[test]
fn classifies_admin_create_provider_key_as_admin_proxy_route() { fn classifies_admin_create_provider_key_as_admin_proxy_route() {
let headers = http::HeaderMap::new(); let headers = http::HeaderMap::new();

View File

@@ -2,6 +2,7 @@ mod batch;
mod create; mod create;
mod delete; mod delete;
mod oauth_invalid; mod oauth_invalid;
mod reset_cycle_stats;
mod update; mod update;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -30,6 +31,11 @@ pub(super) async fn maybe_handle(
{ {
return Ok(Some(response)); return Ok(Some(response));
} }
if let Some(response) =
reset_cycle_stats::maybe_handle(state, request_context, request_body).await?
{
return Ok(Some(response));
}
if let Some(response) = create::maybe_handle(state, request_context, request_body).await? { if let Some(response) = create::maybe_handle(state, request_context, request_body).await? {
return Ok(Some(response)); return Ok(Some(response));
} }

View File

@@ -0,0 +1,182 @@
use crate::handlers::admin::provider::shared::paths::admin_reset_cycle_stats_key_id;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::provider_key_status_snapshot_payload;
use crate::GatewayError;
use axum::{
body::{Body, Bytes},
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::{json, Value};
use std::time::{SystemTime, UNIX_EPOCH};
pub(super) async fn maybe_handle(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
_request_body: Option<&Bytes>,
) -> Result<Option<Response<Body>>, GatewayError> {
let Some(decision) = request_context.decision() else {
return Ok(None);
};
if decision.route_family.as_deref() != Some("endpoints_manage")
|| decision.route_kind.as_deref() != Some("reset_cycle_stats")
|| request_context.method() != http::Method::POST
|| !request_context
.path()
.starts_with("/api/admin/endpoints/keys/")
|| !request_context.path().ends_with("/reset-cycle-stats")
{
return Ok(None);
}
let Some(key_id) = admin_reset_cycle_stats_key_id(request_context.path()) else {
return Ok(Some(not_found_response("Key 不存在")));
};
let Some(mut key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(not_found_response(format!("Key {key_id} 不存在"))));
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&key.provider_id))
.await?
.into_iter()
.next()
else {
return Ok(Some(not_found_response(format!(
"Provider {} 不存在",
key.provider_id
))));
};
if !provider.provider_type.trim().eq_ignore_ascii_case("codex") {
return Ok(Some(bad_request_response(
"仅 Codex Provider 支持重置周期统计",
)));
}
let now_unix_secs = current_unix_secs();
let mut status_snapshot = provider_key_status_snapshot_payload(&key, &provider.provider_type);
let reset_windows = reset_codex_cycle_usage_windows(&mut status_snapshot, now_unix_secs);
if reset_windows == 0 {
return Ok(Some(bad_request_response("当前账号没有可重置的周期窗口")));
}
key.status_snapshot = Some(status_snapshot);
key.updated_at_unix_secs = Some(now_unix_secs);
let Some(_) = state.update_provider_catalog_key(&key).await? else {
return Ok(None);
};
Ok(Some(
Json(json!({
"message": "已重置周期统计",
"reset_at": now_unix_secs,
"windows": reset_windows,
}))
.into_response(),
))
}
fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn reset_codex_cycle_usage_windows(status_snapshot: &mut Value, now_unix_secs: u64) -> usize {
let Some(windows) = status_snapshot
.get_mut("quota")
.and_then(Value::as_object_mut)
.and_then(|quota| quota.get_mut("windows"))
.and_then(Value::as_array_mut)
else {
return 0;
};
let mut reset_count = 0;
for window in windows.iter_mut().filter_map(Value::as_object_mut) {
let code = window
.get("code")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if !code.eq_ignore_ascii_case("5h") && !code.eq_ignore_ascii_case("weekly") {
continue;
}
window.insert("usage_reset_at".to_string(), json!(now_unix_secs));
window.remove("usage");
reset_count += 1;
}
reset_count
}
fn bad_request_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::BAD_REQUEST,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
fn not_found_response(detail: impl Into<String>) -> Response<Body> {
(
http::StatusCode::NOT_FOUND,
Json(json!({ "detail": detail.into() })),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::reset_codex_cycle_usage_windows;
use serde_json::json;
#[test]
fn reset_cycle_usage_marks_codex_windows_and_removes_stale_usage() {
let mut snapshot = json!({
"quota": {
"windows": [
{
"code": "5h",
"usage": {
"request_count": 9,
"total_tokens": 100,
"total_cost_usd": "1.00000000"
}
},
{
"code": "weekly",
"usage": {
"request_count": 10,
"total_tokens": 200,
"total_cost_usd": "2.00000000"
}
},
{
"code": "monthly",
"usage": {
"request_count": 11
}
}
]
}
});
assert_eq!(reset_codex_cycle_usage_windows(&mut snapshot, 1_234), 2);
let windows = snapshot["quota"]["windows"].as_array().expect("windows");
assert_eq!(windows[0]["usage_reset_at"], json!(1_234));
assert!(windows[0].get("usage").is_none());
assert_eq!(windows[1]["usage_reset_at"], json!(1_234));
assert!(windows[1].get("usage").is_none());
assert!(windows[2].get("usage_reset_at").is_none());
assert!(windows[2].get("usage").is_some());
}
}

View File

@@ -11,7 +11,7 @@ use axum::{
Json, Json,
}; };
use serde_json::json; use serde_json::json;
use std::collections::BTreeSet; use std::collections::{BTreeMap, BTreeSet};
use super::super::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally; use super::super::oauth::quota::antigravity::refresh_antigravity_provider_quota_locally;
use super::super::oauth::quota::chatgpt_web::refresh_chatgpt_web_provider_quota_locally; use super::super::oauth::quota::chatgpt_web::refresh_chatgpt_web_provider_quota_locally;
@@ -138,20 +138,28 @@ pub(super) async fn maybe_handle(
)); ));
}; };
let mut keys = state let keys = if let Some(selected_key_ids) = selected_key_ids.as_ref() {
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider_id))
.await?;
keys = if let Some(selected_key_ids) = selected_key_ids.as_ref() {
if selected_key_ids.is_empty() { if selected_key_ids.is_empty() {
Vec::new() Vec::new()
} else { } else {
let selected = selected_key_ids.iter().cloned().collect::<BTreeSet<_>>(); let selected = selected_key_ids.iter().cloned().collect::<BTreeSet<_>>();
keys.into_iter() let mut by_id = state
.filter(|key| selected.contains(&key.id)) .read_provider_catalog_keys_by_ids(selected_key_ids)
.await?
.into_iter()
.filter(|key| key.provider_id == provider_id && selected.contains(&key.id))
.map(|key| (key.id.clone(), key))
.collect::<BTreeMap<_, _>>();
selected_key_ids
.iter()
.filter_map(|key_id| by_id.remove(key_id))
.collect() .collect()
} }
} else { } else {
keys.into_iter() state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider_id))
.await?
.into_iter()
.filter(|key| { .filter(|key| {
key.is_active key.is_active
|| key || key

View File

@@ -16,8 +16,13 @@ fn oauth_invalid_reason_is_account_level_block(reason: Option<&str>) -> bool {
if reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX) { if reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX) {
return true; return true;
} }
aether_admin::provider::status::resolve_account_status_snapshot(None, None, Some(reason)) let snapshot =
.blocked aether_admin::provider::status::resolve_account_status_snapshot(None, None, Some(reason));
snapshot.blocked
&& !matches!(
snapshot.code.trim().to_ascii_lowercase().as_str(),
"oauth_token_invalid" | "oauth_expired" | "oauth_refresh_failed"
)
} }
pub(crate) fn build_internal_control_error_response( pub(crate) fn build_internal_control_error_response(
@@ -142,7 +147,7 @@ pub(crate) fn merge_provider_oauth_refresh_failure_reason(
return Some(refresh_reason.to_string()); return Some(refresh_reason.to_string());
} }
if current_reason.starts_with(OAUTH_EXPIRED_PREFIX) { if current_reason.starts_with(OAUTH_EXPIRED_PREFIX) {
return None; return Some(refresh_reason.to_string());
} }
if oauth_invalid_reason_is_account_level_block(Some(current_reason)) { if oauth_invalid_reason_is_account_level_block(Some(current_reason)) {
return None; return None;
@@ -183,4 +188,15 @@ mod tests {
None, None,
); );
} }
#[test]
fn refresh_failure_replaces_access_token_expired_marker() {
assert_eq!(
merge_provider_oauth_refresh_failure_reason(
Some("[OAUTH_EXPIRED] access token invalid"),
"[REFRESH_FAILED] Token 续期失败 (401): refresh_token 无效",
),
Some("[REFRESH_FAILED] Token 续期失败 (401): refresh_token 无效".to_string()),
);
}
} }

View File

@@ -16,22 +16,40 @@ const MAX_POOL_COOLDOWN_SECONDS: u64 = 32 * 60;
const ACCOUNT_DISABLE_PATTERNS: &[&str] = &[ const ACCOUNT_DISABLE_PATTERNS: &[&str] = &[
"organization has been disabled", "organization has been disabled",
"organization disabled",
"organization_disabled", "organization_disabled",
"account has been disabled", "account has been disabled",
"account disabled",
"account_disabled", "account_disabled",
"account has been deactivated", "account has been deactivated",
"account_deactivated", "account_deactivated",
"account deactivated", "account deactivated",
]; ];
const WORKSPACE_DISABLE_PATTERNS: &[&str] = &[
"deactivated_workspace",
"workspace has been disabled",
"workspace disabled",
"workspace has been deactivated",
"workspace deactivated",
"workspace is disabled",
"workspace is deactivated",
];
const FORBIDDEN_ACCOUNT_PATTERNS: &[&str] = &[ const FORBIDDEN_ACCOUNT_PATTERNS: &[&str] = &[
"account suspended", "account suspended",
"account suspend",
"account banned", "account banned",
"account blocked",
"account forbidden",
"account deactivated", "account deactivated",
"account access denied",
"subscription inactive", "subscription inactive",
"suspended", "suspended",
"banned", "banned",
"blocked",
"deactivated", "deactivated",
"access denied",
]; ];
fn current_unix_secs_f64() -> f64 { fn current_unix_secs_f64() -> f64 {
@@ -178,10 +196,9 @@ fn extract_error_message(error_body: Option<&str>) -> String {
.as_object() .as_object()
.and_then(|object| object.get("error").or_else(|| object.get("message"))) .and_then(|object| object.get("error").or_else(|| object.get("message")))
.and_then(|error| match error { .and_then(|error| match error {
serde_json::Value::Object(object) => object serde_json::Value::Object(object) => {
.get("message") first_error_text(object, &["message", "detail", "reason", "code", "status"])
.and_then(serde_json::Value::as_str) }
.map(ToOwned::to_owned),
serde_json::Value::String(text) => Some(text.clone()), serde_json::Value::String(text) => Some(text.clone()),
_ => None, _ => None,
}) })
@@ -189,11 +206,32 @@ fn extract_error_message(error_body: Option<&str>) -> String {
.unwrap_or_else(|| error_body.chars().take(500).collect()) .unwrap_or_else(|| error_body.chars().take(500).collect())
} }
fn first_error_text(
object: &serde_json::Map<String, serde_json::Value>,
keys: &[&str],
) -> Option<String> {
keys.iter().find_map(|key| {
let text = object.get(*key).and_then(|value| match value {
serde_json::Value::String(text) => Some(text.trim().to_string()),
serde_json::Value::Number(number) => Some(number.to_string()),
_ => None,
})?;
(!text.is_empty()).then_some(text)
})
}
pub(crate) fn admin_provider_pool_key_circuit_breaker_reason( pub(crate) fn admin_provider_pool_key_circuit_breaker_reason(
status_code: u16, status_code: u16,
error_body: Option<&str>, error_body: Option<&str>,
) -> Option<String> { ) -> Option<String> {
let error_message = extract_error_message(error_body).to_ascii_lowercase(); let error_message = extract_error_message(error_body).to_ascii_lowercase();
if let Some(pattern) = WORKSPACE_DISABLE_PATTERNS
.iter()
.find(|pattern| error_message.contains(**pattern))
{
return Some(format!("workspace_deactivated_{status_code}:{pattern}"));
}
match status_code { match status_code {
401 if ACCOUNT_DISABLE_PATTERNS 401 if ACCOUNT_DISABLE_PATTERNS
.iter() .iter()
@@ -212,6 +250,12 @@ pub(crate) fn admin_provider_pool_key_circuit_breaker_reason(
.iter() .iter()
.find(|pattern| error_message.contains(**pattern)) .find(|pattern| error_message.contains(**pattern))
.map(|pattern| format!("account_disabled_400:{pattern}")), .map(|pattern| format!("account_disabled_400:{pattern}")),
423 if FORBIDDEN_ACCOUNT_PATTERNS
.iter()
.any(|pattern| error_message.contains(pattern)) =>
{
Some("account_locked_423".to_string())
}
_ => None, _ => None,
} }
} }
@@ -449,10 +493,10 @@ pub(crate) async fn record_admin_provider_pool_error(
} }
if status_code == 400 { if status_code == 400 {
if admin_provider_pool_key_circuit_breaker_reason(status_code, error_body).is_some() { // Bad Request is usually attributable to the caller payload, not key health.
// Account-level 400s are handled by the orchestration circuit-breaker path.
return; return;
} }
}
if let Some(rule) = if let Some(rule) =
matching_unschedulable_rule(&pool_config.unschedulable_rules, &error_message) matching_unschedulable_rule(&pool_config.unschedulable_rules, &error_message)
@@ -709,6 +753,46 @@ mod tests {
assert_eq!(message_cooldown, Some(2_700)); assert_eq!(message_cooldown, Some(2_700));
} }
#[test]
fn circuit_reason_detects_workspace_deactivated_errors() {
assert_eq!(
admin_provider_pool_key_circuit_breaker_reason(
402,
Some(r#"{"error":{"message":"workspace has been deactivated"}}"#),
)
.as_deref(),
Some("workspace_deactivated_402:workspace has been deactivated")
);
assert_eq!(
admin_provider_pool_key_circuit_breaker_reason(
400,
Some(r#"{"error":{"message":"deactivated_workspace"}}"#),
)
.as_deref(),
Some("workspace_deactivated_400:deactivated_workspace")
);
}
#[test]
fn circuit_reason_detects_account_ban_errors() {
assert_eq!(
admin_provider_pool_key_circuit_breaker_reason(
403,
Some(r#"{"error":{"message":"AccountSuspendedException: account suspended"}}"#),
)
.as_deref(),
Some("forbidden_403")
);
assert_eq!(
admin_provider_pool_key_circuit_breaker_reason(
423,
Some(r#"{"error":{"message":"account access denied"}}"#),
)
.as_deref(),
Some("account_locked_423")
);
}
#[tokio::test] #[tokio::test]
async fn success_feedback_writes_sticky_lru_cost_and_latency() { async fn success_feedback_writes_sticky_lru_cost_and_latency() {
let Some(redis) = start_managed_redis_or_skip().await else { let Some(redis) = start_managed_redis_or_skip().await else {
@@ -1025,6 +1109,46 @@ mod tests {
.is_some_and(|ttl| *ttl <= 420 && *ttl >= 380)); .is_some_and(|ttl| *ttl <= 420 && *ttl >= 380));
} }
#[tokio::test]
async fn error_feedback_ignores_client_bad_request_for_cooldown() {
let Some(redis) = start_managed_redis_or_skip().await else {
return;
};
let app = build_runner_app(redis.redis_url(), "pool_runtime_ignore_400");
let runner = app.redis_kv_runner().expect("redis runner should exist");
let mut pool_config = sample_pool_config();
pool_config.unschedulable_rules = vec![AdminProviderPoolUnschedulableRule {
keyword: "review required".to_string(),
duration_minutes: 7,
}];
let key_ids = vec!["key-client-400".to_string()];
record_admin_provider_pool_error(
&runner,
"provider-1",
"key-client-400",
&pool_config,
400,
Some(r#"{"error":{"message":"manual review required before reuse"}}"#),
None,
)
.await;
let runtime = read_admin_provider_pool_runtime_state(
&runner,
"provider-1",
&key_ids,
&pool_config,
None,
)
.await;
assert!(!runtime
.cooldown_reason_by_key
.contains_key("key-client-400"));
assert!(!runtime.cooldown_ttl_by_key.contains_key("key-client-400"));
}
#[tokio::test] #[tokio::test]
async fn stream_timeout_policy_cools_down_after_threshold() { async fn stream_timeout_policy_cools_down_after_threshold() {
let Some(redis) = start_managed_redis_or_skip().await else { let Some(redis) = start_managed_redis_or_skip().await else {

View File

@@ -322,7 +322,11 @@ fn admin_pool_codex_window_usage_bounds(
let reset_at = admin_pool_json_to_u64(window.get("reset_at"))?; let reset_at = admin_pool_json_to_u64(window.get("reset_at"))?;
let window_minutes = admin_pool_json_to_u64(window.get("window_minutes"))?; let window_minutes = admin_pool_json_to_u64(window.get("window_minutes"))?;
let window_seconds = window_minutes.checked_mul(60)?; let window_seconds = window_minutes.checked_mul(60)?;
let start = reset_at.checked_sub(window_seconds)?; let window_start = reset_at.checked_sub(window_seconds)?;
let usage_reset_at = admin_pool_json_to_u64(window.get("usage_reset_at"))
.filter(|reset_at_override| *reset_at_override < reset_at)
.unwrap_or(window_start);
let start = window_start.max(usage_reset_at);
(start < reset_at).then_some((start, reset_at)) (start < reset_at).then_some((start, reset_at))
} }
@@ -1226,3 +1230,41 @@ pub(super) fn build_admin_pool_key_payload(
serde_json::Value::Object(payload) serde_json::Value::Object(payload)
} }
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn codex_window_usage_bounds_honor_manual_usage_reset_at() {
let window = json!({
"code": "5h",
"reset_at": 20_000,
"window_minutes": 300,
"usage_reset_at": 9_000,
});
let window = window.as_object().expect("window object");
assert_eq!(
admin_pool_codex_window_usage_bounds(window),
Some((9_000, 20_000))
);
}
#[test]
fn codex_window_usage_bounds_ignore_reset_before_window_start() {
let window = json!({
"code": "weekly",
"reset_at": 700_000,
"window_minutes": 10_080,
"usage_reset_at": 1,
});
let window = window.as_object().expect("window object");
assert_eq!(
admin_pool_codex_window_usage_bounds(window),
Some((95_200, 700_000))
);
}
}

View File

@@ -255,23 +255,34 @@ pub(super) async fn build_admin_pool_list_keys_response(
}; };
let codex_window_usage_requests = let codex_window_usage_requests =
pool_payloads::build_admin_pool_codex_window_usage_requests(&provider.provider_type, &keys); pool_payloads::build_admin_pool_codex_window_usage_requests(&provider.provider_type, &keys);
let codex_window_usage_by_key: pool_payloads::AdminPoolCodexWindowUsageByKey = let mut codex_window_usage_by_key = codex_window_usage_requests
if codex_window_usage_requests.is_empty() { .iter()
pool_payloads::AdminPoolCodexWindowUsageByKey::new() .map(|request| {
} else { (
state (
request.provider_api_key_id.clone(),
request.window_code.clone(),
),
aether_data_contracts::repository::usage::StoredProviderApiKeyWindowUsageSummary {
provider_api_key_id: request.provider_api_key_id.clone(),
window_code: request.window_code.clone(),
..Default::default()
},
)
})
.collect::<pool_payloads::AdminPoolCodexWindowUsageByKey>();
if !codex_window_usage_requests.is_empty() {
for usage in state
.app() .app()
.summarize_usage_by_provider_api_key_windows(&codex_window_usage_requests) .summarize_usage_by_provider_api_key_windows(&codex_window_usage_requests)
.await? .await?
.into_iter() {
.map(|usage| { codex_window_usage_by_key.insert(
(
(usage.provider_api_key_id.clone(), usage.window_code.clone()), (usage.provider_api_key_id.clone(), usage.window_code.clone()),
usage, usage,
) );
}) }
.collect() }
};
let items = keys let items = keys
.into_iter() .into_iter()

View File

@@ -37,7 +37,7 @@ pub(crate) struct AdminPoolKeySort {
impl Default for AdminPoolKeySort { impl Default for AdminPoolKeySort {
fn default() -> Self { fn default() -> Self {
Self { Self {
field: AdminPoolKeySortField::Default, field: AdminPoolKeySortField::ImportedAt,
direction: AdminPoolKeySortDirection::Desc, direction: AdminPoolKeySortDirection::Desc,
} }
} }
@@ -117,7 +117,8 @@ pub(crate) fn parse_admin_pool_key_sort(query: Option<&str>) -> Result<AdminPool
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.as_deref() .as_deref()
{ {
None | Some("default") | Some("name") => AdminPoolKeySortField::Default, None | Some("default") => AdminPoolKeySortField::ImportedAt,
Some("name") => AdminPoolKeySortField::Default,
Some("imported_at") | Some("created_at") => AdminPoolKeySortField::ImportedAt, Some("imported_at") | Some("created_at") => AdminPoolKeySortField::ImportedAt,
Some("last_used_at") | Some("last_used") => AdminPoolKeySortField::LastUsedAt, Some("last_used_at") | Some("last_used") => AdminPoolKeySortField::LastUsedAt,
Some(_) => { Some(_) => {

View File

@@ -33,6 +33,13 @@ pub(crate) fn admin_clear_oauth_invalid_key_id(request_path: &str) -> Option<Str
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
} }
pub(crate) fn admin_reset_cycle_stats_key_id(request_path: &str) -> Option<String> {
request_path
.strip_prefix("/api/admin/endpoints/keys/")?
.strip_suffix("/reset-cycle-stats")
.map(ToOwned::to_owned)
}
pub(crate) fn admin_update_key_id(request_path: &str) -> Option<String> { pub(crate) fn admin_update_key_id(request_path: &str) -> Option<String> {
let key_id = request_path.strip_prefix("/api/admin/endpoints/keys/")?; let key_id = request_path.strip_prefix("/api/admin/endpoints/keys/")?;
(!key_id.is_empty() && !key_id.contains('/')).then_some(key_id.to_string()) (!key_id.is_empty() && !key_id.contains('/')).then_some(key_id.to_string())

View File

@@ -16,7 +16,8 @@ pub(crate) use self::crud::{
}; };
pub(crate) use self::endpoint_keys::{ pub(crate) use self::endpoint_keys::{
admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_keys, admin_clear_oauth_invalid_key_id, admin_export_key_id, admin_provider_id_for_keys,
admin_provider_id_for_refresh_quota, admin_reveal_key_id, admin_update_key_id, admin_provider_id_for_refresh_quota, admin_reset_cycle_stats_key_id, admin_reveal_key_id,
admin_update_key_id,
}; };
pub(crate) use self::oauth::{ pub(crate) use self::oauth::{
admin_provider_oauth_batch_import_provider_id, admin_provider_oauth_batch_import_task_path, admin_provider_oauth_batch_import_provider_id, admin_provider_oauth_batch_import_task_path,

View File

@@ -556,6 +556,52 @@ fn quota_windows_all_exhausted(windows: &[Value]) -> bool {
total > 0 && exhausted == total total > 0 && exhausted == total
} }
fn preserve_quota_window_usage_reset_at(
current_status_snapshot: Option<&Value>,
quota: &mut Value,
) {
let Some(current_windows) = current_status_snapshot
.and_then(Value::as_object)
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object)
.and_then(|quota| quota.get("windows"))
.and_then(Value::as_array)
else {
return;
};
let Some(next_windows) = quota.get_mut("windows").and_then(Value::as_array_mut) else {
return;
};
for next_window in next_windows.iter_mut().filter_map(Value::as_object_mut) {
let Some(code) = next_window
.get("code")
.and_then(Value::as_str)
.map(str::trim)
.filter(|code| !code.is_empty())
else {
continue;
};
let Some(usage_reset_at) = current_windows
.iter()
.filter_map(Value::as_object)
.find(|current_window| {
current_window
.get("code")
.and_then(Value::as_str)
.map(str::trim)
.is_some_and(|current_code| current_code.eq_ignore_ascii_case(code))
})
.and_then(|current_window| current_window.get("usage_reset_at"))
.and_then(admin_provider_quota_pure::coerce_json_u64)
else {
continue;
};
next_window.insert("usage_reset_at".to_string(), json!(usage_reset_at));
}
}
fn codex_quota_window_snapshot( fn codex_quota_window_snapshot(
metadata: &Map<String, Value>, metadata: &Map<String, Value>,
prefix: &str, prefix: &str,
@@ -1107,7 +1153,7 @@ pub(crate) fn sync_provider_key_quota_status_snapshot(
source: &str, source: &str,
) -> Option<Value> { ) -> Option<Value> {
let normalized_provider_type = provider_type.trim().to_ascii_lowercase(); let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
let quota = match normalized_provider_type.as_str() { let mut quota = match normalized_provider_type.as_str() {
"codex" => build_codex_quota_status_snapshot(upstream_metadata, source), "codex" => build_codex_quota_status_snapshot(upstream_metadata, source),
"kiro" => build_kiro_quota_status_snapshot(upstream_metadata, source), "kiro" => build_kiro_quota_status_snapshot(upstream_metadata, source),
"chatgpt_web" => build_chatgpt_web_quota_status_snapshot(upstream_metadata, source), "chatgpt_web" => build_chatgpt_web_quota_status_snapshot(upstream_metadata, source),
@@ -1115,6 +1161,9 @@ pub(crate) fn sync_provider_key_quota_status_snapshot(
"gemini_cli" => build_gemini_cli_quota_status_snapshot(upstream_metadata, source), "gemini_cli" => build_gemini_cli_quota_status_snapshot(upstream_metadata, source),
_ => None, _ => None,
}?; }?;
if normalized_provider_type == "codex" {
preserve_quota_window_usage_reset_at(status_snapshot, &mut quota);
}
let default_snapshot = default_provider_key_status_snapshot(); let default_snapshot = default_provider_key_status_snapshot();
let mut snapshot = provider_key_status_snapshot_object(status_snapshot) let mut snapshot = provider_key_status_snapshot_object(status_snapshot)
@@ -2012,6 +2061,69 @@ mod tests {
); );
} }
#[test]
fn sync_provider_key_quota_status_snapshot_preserves_codex_usage_reset_at() {
let current_status_snapshot = json!({
"quota": {
"version": 2,
"provider_type": "codex",
"windows": [
{
"code": "weekly",
"usage_reset_at": 1_775_600_000u64,
"reset_at": 1_900_000_000u64,
"window_minutes": 10_080u64
},
{
"code": "5h",
"usage_reset_at": 1_775_700_000u64,
"reset_at": 1_900_500_000u64,
"window_minutes": 300u64
}
]
}
});
let upstream_metadata = json!({
"codex": {
"updated_at": 1_775_800_000u64,
"plan_type": "plus",
"primary_used_percent": 5.0,
"primary_reset_at": 1_901_000_000u64,
"primary_window_minutes": 10_080u64,
"secondary_used_percent": 1.0,
"secondary_reset_at": 1_901_500_000u64,
"secondary_window_minutes": 300u64
}
});
let payload = sync_provider_key_quota_status_snapshot(
Some(&current_status_snapshot),
"codex",
Some(&upstream_metadata),
"refresh_api",
)
.expect("quota snapshot should sync");
let windows = payload
.get("quota")
.and_then(Value::as_object)
.and_then(|quota| quota.get("windows"))
.and_then(Value::as_array)
.expect("quota windows should exist");
let weekly = windows
.iter()
.filter_map(Value::as_object)
.find(|window| window.get("code") == Some(&json!("weekly")))
.expect("weekly window should exist");
let five_h = windows
.iter()
.filter_map(Value::as_object)
.find(|window| window.get("code") == Some(&json!("5h")))
.expect("5h window should exist");
assert_eq!(weekly.get("usage_reset_at"), Some(&json!(1_775_600_000u64)));
assert_eq!(five_h.get("usage_reset_at"), Some(&json!(1_775_700_000u64)));
}
#[test] #[test]
fn provider_key_status_snapshot_payload_backfills_thin_ok_snapshot_from_upstream_metadata() { fn provider_key_status_snapshot_payload_backfills_thin_ok_snapshot_from_upstream_metadata() {
let mut key = sample_catalog_key(); let mut key = sample_catalog_key();

View File

@@ -731,6 +731,10 @@ fn local_candidate_failure_should_record_pool_error(
classification: LocalFailoverClassification, classification: LocalFailoverClassification,
status_code: u16, status_code: u16,
) -> bool { ) -> bool {
if status_code == 400 {
return false;
}
local_candidate_failure_should_invalidate_affinity(classification, status_code) local_candidate_failure_should_invalidate_affinity(classification, status_code)
} }
@@ -1441,6 +1445,10 @@ mod tests {
LocalFailoverClassification::StopErrorPattern, LocalFailoverClassification::StopErrorPattern,
400, 400,
)); ));
assert!(!local_candidate_failure_should_record_pool_error(
LocalFailoverClassification::RetryUpstreamFailure,
400,
));
assert!(local_candidate_failure_should_record_pool_error( assert!(local_candidate_failure_should_record_pool_error(
LocalFailoverClassification::RetryUpstreamFailure, LocalFailoverClassification::RetryUpstreamFailure,
429, 429,

View File

@@ -144,6 +144,9 @@ fn local_candidate_failure_should_project_health(
if status_code < 400 { if status_code < 400 {
return false; return false;
} }
if status_code == 400 {
return false;
}
match classification { match classification {
LocalFailoverClassification::RetrySuccessPattern LocalFailoverClassification::RetrySuccessPattern
@@ -216,6 +219,18 @@ mod tests {
.is_none()); .is_none());
} }
#[test]
fn failure_projection_ignores_client_bad_request() {
assert!(project_local_failure_health(
None,
"openai:chat",
LocalFailoverClassification::RetryUpstreamFailure,
400,
1_760_000_000,
)
.is_none());
}
#[test] #[test]
fn success_projection_resets_only_target_format() { fn success_projection_resets_only_target_format() {
let projected = project_local_success_health( let projected = project_local_success_health(

View File

@@ -366,6 +366,12 @@ fn oauth_invalid_reason_is_hard_account_block(
provider_type: &str, provider_type: &str,
invalid_reason: &str, invalid_reason: &str,
) -> bool { ) -> bool {
if provider_type.trim().eq_ignore_ascii_case("kiro")
&& invalid_reason.trim().starts_with("[REFRESH_FAILED] ")
{
return true;
}
let account_state = admin_provider_status_pure::resolve_pool_account_state( let account_state = admin_provider_status_pure::resolve_pool_account_state(
Some(provider_type), Some(provider_type),
key.upstream_metadata.as_ref(), key.upstream_metadata.as_ref(),

View File

@@ -1971,7 +1971,7 @@ async fn keeps_refreshable_kiro_candidate_selectable_when_oauth_token_expired()
} }
#[tokio::test] #[tokio::test]
async fn keeps_kiro_candidate_selectable_after_refresh_failure() { async fn skips_kiro_candidate_after_refresh_token_failure() {
let mut row = sample_row(); let mut row = sample_row();
row.provider_id = "provider-kiro".to_string(); row.provider_id = "provider-kiro".to_string();
row.provider_name = "kiro".to_string(); row.provider_name = "kiro".to_string();
@@ -2028,9 +2028,10 @@ async fn keeps_kiro_candidate_selectable_after_refresh_failure() {
.await .await
.expect("selection should succeed"); .expect("selection should succeed");
assert_eq!(selected.len(), 1); assert!(selected.is_empty());
assert_eq!(selected[0].provider_id, "provider-kiro"); assert_eq!(skipped.len(), 1);
assert!(skipped.is_empty()); assert_eq!(skipped[0].candidate.key_id, "key-kiro");
assert_eq!(skipped[0].skip_reason, "oauth_invalid");
} }
#[tokio::test] #[tokio::test]

View File

@@ -151,8 +151,13 @@ fn oauth_invalid_reason_is_account_block(reason: Option<&str>) -> bool {
if reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX) { if reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX) {
return true; return true;
} }
aether_admin::provider::status::resolve_account_status_snapshot(None, None, Some(reason)) let snapshot =
.blocked aether_admin::provider::status::resolve_account_status_snapshot(None, None, Some(reason));
snapshot.blocked
&& !matches!(
snapshot.code.trim().to_ascii_lowercase().as_str(),
"oauth_token_invalid" | "oauth_expired" | "oauth_refresh_failed"
)
} }
fn normalize_local_oauth_refresh_error_message( fn normalize_local_oauth_refresh_error_message(
@@ -270,7 +275,7 @@ fn merge_local_oauth_refresh_failure_reason(
return Some(refresh_reason.to_string()); return Some(refresh_reason.to_string());
} }
if current_reason.starts_with(OAUTH_EXPIRED_PREFIX) { if current_reason.starts_with(OAUTH_EXPIRED_PREFIX) {
return None; return Some(refresh_reason.to_string());
} }
if oauth_invalid_reason_is_account_block(Some(current_reason)) { if oauth_invalid_reason_is_account_block(Some(current_reason)) {
return None; return None;
@@ -1681,4 +1686,15 @@ mod tests {
"refresh_token 无效、已过期或已撤销,请重新登录授权" "refresh_token 无效、已过期或已撤销,请重新登录授权"
); );
} }
#[test]
fn local_refresh_failure_replaces_access_token_expired_marker() {
assert_eq!(
super::merge_local_oauth_refresh_failure_reason(
Some("[OAUTH_EXPIRED] access token invalid"),
"[REFRESH_FAILED] Token 续期失败 (401): refresh_token 无效",
),
Some("[REFRESH_FAILED] Token 续期失败 (401): refresh_token 无效".to_string()),
);
}
} }

View File

@@ -798,6 +798,28 @@ async fn gateway_sorts_admin_pool_keys_by_imported_and_last_used_time() {
provider_catalog_repository, provider_catalog_repository,
)); ));
let default_response = local_admin_pool_response(
&state,
http::Method::GET,
"/api/admin/pool/provider-openai/keys?page=1&page_size=50&status=all",
None,
)
.await;
assert_eq!(default_response.status(), StatusCode::OK);
let default_payload: serde_json::Value = serde_json::from_slice(
&to_bytes(default_response.into_body(), usize::MAX)
.await
.expect("body should read"),
)
.expect("json body should parse");
let default_names = default_payload["keys"]
.as_array()
.expect("keys should be array")
.iter()
.map(|item| item["key_name"].as_str().unwrap_or_default())
.collect::<Vec<_>>();
assert_eq!(default_names, vec!["fresh", "active", "old"]);
let imported_response = local_admin_pool_response( let imported_response = local_admin_pool_response(
&state, &state,
http::Method::GET, http::Method::GET,

View File

@@ -136,7 +136,7 @@ fn classify_block_reason(reason: &str) -> (&'static str, &'static str) {
.iter() .iter()
.any(|keyword| lowered.contains(keyword)) .any(|keyword| lowered.contains(keyword))
{ {
return ("oauth_expired", "Token 失效"); return ("oauth_token_invalid", "Token 失效");
} }
if looks_like_account_verification(reason) { if looks_like_account_verification(reason) {
return ("account_verification", "需要验证"); return ("account_verification", "需要验证");
@@ -344,23 +344,23 @@ fn resolve_from_oauth_invalid_reason(reason: Option<&str>) -> Option<PoolAccount
.unwrap_or_else(|| "OAuth Token 已过期且无法续期".to_string()); .unwrap_or_else(|| "OAuth Token 已过期且无法续期".to_string());
return Some(PoolAccountState { return Some(PoolAccountState {
blocked: true, blocked: true,
code: Some("oauth_expired".to_string()), code: Some("oauth_token_invalid".to_string()),
label: Some("Token 失效".to_string()), label: Some("Token 失效".to_string()),
reason: Some(cleaned), reason: Some(cleaned),
source: Some("oauth_invalid".to_string()), source: Some("oauth_invalid".to_string()),
recoverable: true, recoverable: false,
}); });
} }
if text.starts_with(OAUTH_REFRESH_FAILED_PREFIX) { if text.starts_with(OAUTH_REFRESH_FAILED_PREFIX) {
let cleaned = clean_text(Some(text.trim_start_matches(OAUTH_REFRESH_FAILED_PREFIX))) let cleaned = clean_text(Some(text.trim_start_matches(OAUTH_REFRESH_FAILED_PREFIX)))
.unwrap_or_else(|| "OAuth Token 续期失败".to_string()); .unwrap_or_else(|| "OAuth Token 续期失败".to_string());
return Some(PoolAccountState { return Some(PoolAccountState {
blocked: false, blocked: true,
code: Some("oauth_refresh_failed".to_string()), code: Some("oauth_token_invalid".to_string()),
label: Some("续期失败".to_string()), label: Some("Token 失效".to_string()),
reason: Some(cleaned), reason: Some(cleaned),
source: Some("oauth_refresh".to_string()), source: Some("oauth_refresh".to_string()),
recoverable: true, recoverable: false,
}); });
} }
if text.starts_with(OAUTH_REQUEST_FAILED_PREFIX) { if text.starts_with(OAUTH_REQUEST_FAILED_PREFIX) {
@@ -446,6 +446,38 @@ pub fn resolve_account_status_snapshot(
}; };
} }
if let Some(cleaned) = tagged_reason(&text, "OAUTH_EXPIRED") {
let reason = if cleaned.is_empty() {
"OAuth Token 已过期且无法续期".to_string()
} else {
cleaned
};
return AccountStatusSnapshot {
code: "oauth_token_invalid".to_string(),
label: Some("Token 失效".to_string()),
reason: Some(reason),
blocked: true,
source: Some("oauth_invalid".to_string()),
recoverable: false,
};
}
if let Some(cleaned) = tagged_reason(&text, "REFRESH_FAILED") {
let reason = if cleaned.is_empty() {
"OAuth Token 续期失败".to_string()
} else {
cleaned
};
return AccountStatusSnapshot {
code: "oauth_token_invalid".to_string(),
label: Some("Token 失效".to_string()),
reason: Some(reason),
blocked: true,
source: Some("oauth_refresh".to_string()),
recoverable: false,
};
}
if text.starts_with('[') { if text.starts_with('[') {
return AccountStatusSnapshot::default(); return AccountStatusSnapshot::default();
} }
@@ -546,29 +578,46 @@ mod tests {
} }
#[test] #[test]
fn resolves_refresh_failed_as_recoverable_pool_state() { fn resolves_refresh_failed_as_token_invalid_pool_state() {
let state = resolve_pool_account_state( let state = resolve_pool_account_state(
Some("codex"), Some("codex"),
None, None,
Some("[REFRESH_FAILED] Token 续期失败 (401): refresh_token 已失效"), Some("[REFRESH_FAILED] Token 续期失败 (401): refresh_token 已失效"),
); );
assert!(!state.blocked); assert!(state.blocked);
assert!(state.recoverable); assert!(!state.recoverable);
assert_eq!(state.code.as_deref(), Some("oauth_refresh_failed")); assert_eq!(state.code.as_deref(), Some("oauth_token_invalid"));
assert_eq!(state.label.as_deref(), Some("Token 失效"));
assert!(!should_auto_remove_account_state(&state)); assert!(!should_auto_remove_account_state(&state));
} }
#[test] #[test]
fn account_snapshot_ignores_refresh_failed_reason() { fn account_snapshot_marks_refresh_failed_as_token_invalid() {
let snapshot = resolve_account_status_snapshot( let snapshot = resolve_account_status_snapshot(
Some("codex"), Some("codex"),
None, None,
Some("[REFRESH_FAILED] Token 续期失败 (401): refresh_token 已失效"), Some("[REFRESH_FAILED] Token 续期失败 (401): refresh_token 已失效"),
); );
assert_eq!(snapshot.code, "ok"); assert_eq!(snapshot.code, "oauth_token_invalid");
assert!(!snapshot.blocked); assert_eq!(snapshot.label.as_deref(), Some("Token 失效"));
assert!(snapshot.blocked);
assert!(!snapshot.recoverable);
}
#[test]
fn account_snapshot_marks_oauth_expired_as_token_invalid() {
let snapshot = resolve_account_status_snapshot(
Some("codex"),
None,
Some("[OAUTH_EXPIRED] Codex Token 无效或已过期 (401)"),
);
assert_eq!(snapshot.code, "oauth_token_invalid");
assert_eq!(snapshot.label.as_deref(), Some("Token 失效"));
assert!(snapshot.blocked);
assert!(!snapshot.recoverable);
} }
#[test] #[test]

View File

@@ -267,12 +267,16 @@ fn transport_key_allows_candidate_model(
.mapping_matched_model .mapping_matched_model
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
let requested_base_model = aether_ai_formats::model_directive_base_model(requested_model);
for allowed_model in allowed_models.iter().map(String::as_str).map(str::trim) { for allowed_model in allowed_models.iter().map(String::as_str).map(str::trim) {
if allowed_model.is_empty() { if allowed_model.is_empty() {
continue; continue;
} }
if allowed_model == requested_model if allowed_model == requested_model
|| requested_base_model
.as_deref()
.is_some_and(|base_model| allowed_model == base_model)
|| allowed_model == global_model_name || allowed_model == global_model_name
|| allowed_model == selected_provider_model_name || allowed_model == selected_provider_model_name
|| mapping_matched_model.is_some_and(|value| value == allowed_model) || mapping_matched_model.is_some_and(|value| value == allowed_model)
@@ -553,6 +557,26 @@ mod tests {
); );
} }
#[test]
fn candidate_common_transport_policy_allows_model_directive_base_model() {
let mut transport = transport_snapshot("custom", "openai:responses", "bearer", true, None);
transport.key.allowed_models = Some(vec!["gpt-5.5".to_string()]);
assert_eq!(
candidate_common_transport_skip_reason(
&transport,
CandidateTransportPolicyFacts {
endpoint_api_format: "openai:responses",
global_model_name: "gpt-5",
selected_provider_model_name: "provider-gpt-5",
mapping_matched_model: None,
},
Some("gpt-5.5-xhigh"),
),
None
);
}
#[test] #[test]
fn fixed_provider_oauth_keys_inherit_endpoint_api_formats_for_candidate_policy() { fn fixed_provider_oauth_keys_inherit_endpoint_api_formats_for_candidate_policy() {
let mut transport = transport_snapshot("codex", "openai:responses", "oauth", true, None); let mut transport = transport_snapshot("codex", "openai:responses", "oauth", true, None);

View File

@@ -209,6 +209,18 @@ export async function clearOAuthInvalid(keyId: string): Promise<{ message: strin
return response.data return response.data
} }
/**
* 重置 Key 的当前周期统计起点Codex 号池)
*/
export async function resetProviderKeyCycleStats(keyId: string): Promise<{
message: string
reset_at: number
windows: number
}> {
const response = await client.post(`/api/admin/endpoints/keys/${keyId}/reset-cycle-stats`)
return response.data
}
/** /**
* 刷新 Provider 的所有 Key 限额信息Codex / Antigravity * 刷新 Provider 的所有 Key 限额信息Codex / Antigravity
*/ */

View File

@@ -107,7 +107,7 @@ describe('poolManagementState', () => {
status: 'all', status: 'all',
page: 1, page: 1,
pageSize: 50, pageSize: 50,
sortBy: null, sortBy: 'imported_at',
sortOrder: 'desc', sortOrder: 'desc',
statsMode: 'current_cycle', statsMode: 'current_cycle',
}), }),

View File

@@ -36,6 +36,7 @@ describe('poolMobilePresentation', () => {
canRefreshToken: true, canRefreshToken: true,
canClearCooldown: true, canClearCooldown: true,
canRecoverHealth: true, canRecoverHealth: true,
canResetCycleStats: true,
canDownloadOrCopy: true, canDownloadOrCopy: true,
hasProxy: true, hasProxy: true,
}), }),
@@ -43,6 +44,7 @@ describe('poolMobilePresentation', () => {
primary: [ primary: [
'copy_or_download', 'copy_or_download',
'refresh_token', 'refresh_token',
'reset_cycle_stats',
'clear_cooldown', 'clear_cooldown',
'recover_health', 'recover_health',
'permissions', 'permissions',

View File

@@ -43,7 +43,7 @@ export const DEFAULT_POOL_MANAGEMENT_VIEW_STATE: PoolManagementViewState = {
status: 'all', status: 'all',
page: 1, page: 1,
pageSize: 50, pageSize: 50,
sortBy: null, sortBy: 'imported_at',
sortOrder: 'desc', sortOrder: 'desc',
statsMode: 'current_cycle', statsMode: 'current_cycle',
} }
@@ -76,7 +76,7 @@ function normalizeSortBy(value: unknown): PoolManagementSortBy | null {
if (value === 'imported_at' || value === 'last_used_at') { if (value === 'imported_at' || value === 'last_used_at') {
return value return value
} }
return null return DEFAULT_POOL_MANAGEMENT_VIEW_STATE.sortBy
} }
function normalizeSortOrder(value: unknown): PoolManagementSortOrder { function normalizeSortOrder(value: unknown): PoolManagementSortOrder {
@@ -156,6 +156,8 @@ export function buildPoolManagementQueryPatch(
): Record<string, string | undefined> { ): Record<string, string | undefined> {
const normalized = normalizeViewState(state) const normalized = normalizeViewState(state)
const search = normalized.search.trim() const search = normalized.search.trim()
const isDefaultSort = normalized.sortBy === DEFAULT_POOL_MANAGEMENT_VIEW_STATE.sortBy
&& normalized.sortOrder === DEFAULT_POOL_MANAGEMENT_VIEW_STATE.sortOrder
return { return {
providerId: normalized.providerId || undefined, providerId: normalized.providerId || undefined,
@@ -166,8 +168,8 @@ export function buildPoolManagementQueryPatch(
normalized.pageSize === DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize normalized.pageSize === DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize
? undefined ? undefined
: String(normalized.pageSize), : String(normalized.pageSize),
sortBy: normalized.sortBy || undefined, sortBy: isDefaultSort ? undefined : normalized.sortBy || undefined,
sortOrder: normalized.sortBy ? normalized.sortOrder : undefined, sortOrder: isDefaultSort ? undefined : normalized.sortBy ? normalized.sortOrder : undefined,
statsMode: normalized.statsMode === 'account_total' ? 'account_total' : undefined, statsMode: normalized.statsMode === 'account_total' ? 'account_total' : undefined,
} }
} }

View File

@@ -21,6 +21,7 @@ export interface PoolMobileTagInput {
export type PoolMobileActionId = export type PoolMobileActionId =
| 'copy_or_download' | 'copy_or_download'
| 'refresh_token' | 'refresh_token'
| 'reset_cycle_stats'
| 'clear_cooldown' | 'clear_cooldown'
| 'recover_health' | 'recover_health'
| 'permissions' | 'permissions'
@@ -33,6 +34,7 @@ export interface PoolMobileActionInput {
canDownloadOrCopy?: boolean canDownloadOrCopy?: boolean
canRefreshToken?: boolean canRefreshToken?: boolean
showRefreshToken?: boolean showRefreshToken?: boolean
canResetCycleStats?: boolean
canClearCooldown?: boolean canClearCooldown?: boolean
canRecoverHealth?: boolean canRecoverHealth?: boolean
hasProxy?: boolean hasProxy?: boolean
@@ -70,6 +72,9 @@ export function splitPoolMobileActions(input: PoolMobileActionInput): {
if (showRefreshToken) { if (showRefreshToken) {
primary.push('refresh_token') primary.push('refresh_token')
} }
if (input.canResetCycleStats) {
primary.push('reset_cycle_stats')
}
if (input.canClearCooldown) { if (input.canClearCooldown) {
primary.push('clear_cooldown') primary.push('clear_cooldown')
} }
@@ -92,6 +97,9 @@ export function splitPoolMobileActions(input: PoolMobileActionInput): {
if (showRefreshToken) { if (showRefreshToken) {
primary.push('refresh_token') primary.push('refresh_token')
} }
if (input.canResetCycleStats) {
primary.push('reset_cycle_stats')
}
if (input.canClearCooldown) { if (input.canClearCooldown) {
primary.push('clear_cooldown') primary.push('clear_cooldown')
} }

View File

@@ -75,19 +75,21 @@
<!-- 子节点同提供商的其他尝试不包含首次 --> <!-- 子节点同提供商的其他尝试不包含首次 -->
<div <div
v-if="group.retryCount > 0 && isGroupSelected(group)" v-if="group.retryCount > 0"
class="sub-dots" class="sub-dots"
> >
<button <button
v-for="(attempt, idx) in group.allAttempts.slice(1)" v-for="(attempt, idx) in group.allAttempts.slice(1)"
:key="attempt.id" :key="attempt.id"
type="button"
class="sub-dot" class="sub-dot"
:class="[ :class="[
getStatusColorClass(getDisplayStatus(attempt)), getStatusColorClass(getDisplayStatus(attempt)),
{ active: selectedAttemptIndex === idx + 1 } { active: isAttemptSelected(group, idx + 1) }
]" ]"
:title="attempt.key_name || `Key ${idx + 2}`" :title="formatAttemptDotTitle(attempt)"
@click.stop="selectedAttemptIndex = idx + 1" :aria-label="formatAttemptDotTitle(attempt)"
@click.stop="selectAttemptInGroup(group, idx + 1)"
/> />
</div> </div>
</div> </div>
@@ -780,20 +782,28 @@ const STATUS_PRIORITY: Record<string, number> = {
success: 4, success: 4,
} }
// 候选时间线(按实际执行顺序排序) const isParticipatedCandidate = (candidate: CandidateRecord): boolean => {
if (candidate.status === 'available' || candidate.status === 'unused') return false
if (candidate.status === 'pending' && !candidate.started_at) return false
return true
}
const compareBySchedulingOrder = (a: CandidateRecord, b: CandidateRecord): number => {
if (a.candidate_index !== b.candidate_index) {
return a.candidate_index - b.candidate_index
}
if (a.retry_index !== b.retry_index) {
return a.retry_index - b.retry_index
}
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
}
// 候选时间线按调度顺序排序lazy 加载的跳过候选通常没有 started_at
const rawTimeline = computed<CandidateRecord[]>(() => { const rawTimeline = computed<CandidateRecord[]>(() => {
if (!trace.value) return [] if (!trace.value) return []
return [...trace.value.candidates] return [...trace.value.candidates]
.filter(c => TIMELINE_STATUS.includes(c.status)) .filter(c => TIMELINE_STATUS.includes(c.status))
.sort((a, b) => { .sort(compareBySchedulingOrder)
const startedA = a.started_at ? new Date(a.started_at).getTime() : Infinity
const startedB = b.started_at ? new Date(b.started_at).getTime() : Infinity
if (startedA !== startedB) return startedA - startedB
if (a.candidate_index !== b.candidate_index) {
return a.candidate_index - b.candidate_index
}
return a.retry_index - b.retry_index
})
}) })
@@ -919,7 +929,7 @@ const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
// 将相同 Provider 的所有请求合并为组(同提供商的 Key 放在子节点) // 将相同 Provider 的所有请求合并为组(同提供商的 Key 放在子节点)
const groupedTimeline = computed<NodeGroup[]>(() => { const groupedTimeline = computed<NodeGroup[]>(() => {
const providerGroups = buildProviderGroups(timeline.value) const providerGroups = buildProviderGroups(timeline.value.filter(isParticipatedCandidate))
if (poolAttemptsByGroup.value.size === 0) { if (poolAttemptsByGroup.value.size === 0) {
return providerGroups return providerGroups
} }
@@ -929,12 +939,7 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
const poolGroups: NodeGroup[] = [] const poolGroups: NodeGroup[] = []
for (const [groupId, attemptsRaw] of poolAttemptsByGroup.value.entries()) { for (const [groupId, attemptsRaw] of poolAttemptsByGroup.value.entries()) {
const attempts = [...attemptsRaw].sort((a, b) => { const attempts = [...attemptsRaw].sort(compareBySchedulingOrder)
if (a.candidate_index !== b.candidate_index) {
return a.candidate_index - b.candidate_index
}
return a.retry_index - b.retry_index
})
if (attempts.length === 0) continue if (attempts.length === 0) continue
const visibleAttempts = buildPoolGroupVisibleAttempts(attempts) const visibleAttempts = buildPoolGroupVisibleAttempts(attempts)
@@ -1638,6 +1643,32 @@ const selectFirstAttempt = (group: NodeGroup) => {
} }
} }
const selectAttemptInGroup = (group: NodeGroup, attemptIndex: number) => {
const groupIndex = groupedTimeline.value.findIndex(g => g.id === group.id && g.startIndex === group.startIndex)
if (groupIndex < 0) return
selectedGroupIndex.value = groupIndex
selectedAttemptIndex.value = attemptIndex
}
const isAttemptSelected = (group: NodeGroup, attemptIndex: number) => {
return isGroupSelected(group) && selectedAttemptIndex.value === attemptIndex
}
const formatCandidateAttemptIndex = (attempt: CandidateRecord): string => {
return attempt.retry_index > 0
? `#${attempt.candidate_index}.${attempt.retry_index}`
: `#${attempt.candidate_index}`
}
const formatAttemptDotTitle = (attempt: CandidateRecord): string => {
const parts = [
formatCandidateAttemptIndex(attempt),
attempt.key_name || attempt.key_account_label || attempt.key_preview || '未知 Key',
getStatusLabel(getDisplayStatus(attempt)),
]
return parts.filter(Boolean).join(' · ')
}
// 导航到上/下一组 // 导航到上/下一组
const navigateGroup = (direction: number) => { const navigateGroup = (direction: number) => {
const newIndex = selectedGroupIndex.value + direction const newIndex = selectedGroupIndex.value + direction
@@ -1837,8 +1868,17 @@ const getStatusColorClass = (status: string) => {
} }
// 展示状态:进行中态优先(包括 started 但未 finished 的中间态),再按 HTTP 状态码兜底 // 展示状态:进行中态优先(包括 started 但未 finished 的中间态),再按 HTTP 状态码兜底
const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string => { function getDisplayStatus(attempt: CandidateRecord | null | undefined): string {
if (!attempt) return 'available' if (!attempt) return 'available'
if (
attempt.status === 'success' ||
attempt.status === 'failed' ||
attempt.status === 'cancelled' ||
attempt.status === 'skipped' ||
attempt.status === 'stream_interrupted'
) {
return attempt.status
}
const hasFinished = Boolean(attempt.finished_at) const hasFinished = Boolean(attempt.finished_at)
const isExplicitPending = (attempt.status === 'pending' || attempt.status === 'streaming') && !hasFinished const isExplicitPending = (attempt.status === 'pending' || attempt.status === 'streaming') && !hasFinished
const isImplicitPending = Boolean( const isImplicitPending = Boolean(

View File

@@ -311,10 +311,10 @@
<colgroup v-if="isAdmin"> <colgroup v-if="isAdmin">
<col class="w-[8%]"> <col class="w-[8%]">
<col class="w-[12%]"> <col class="w-[12%]">
<col class="w-[14%]">
<col class="w-[16%]"> <col class="w-[16%]">
<col class="w-[16%]"> <col class="w-[15%]">
<col class="w-[17%]"> <col class="w-[10%]">
<col class="w-[6%]">
<col class="w-[10%]"> <col class="w-[10%]">
<col class="w-[6%]"> <col class="w-[6%]">
<col class="w-[9%]"> <col class="w-[9%]">
@@ -322,9 +322,9 @@
<colgroup v-else> <colgroup v-else>
<col class="w-[9%]"> <col class="w-[9%]">
<col class="w-[17%]"> <col class="w-[17%]">
<col class="w-[24%]"> <col class="w-[22%]">
<col class="w-[15%]"> <col class="w-[14%]">
<col class="w-[7%]"> <col class="w-[10%]">
<col class="w-[11%]"> <col class="w-[11%]">
<col class="w-[7%]"> <col class="w-[7%]">
<col class="w-[10%]"> <col class="w-[10%]">
@@ -360,7 +360,7 @@
密钥 密钥
</TableHead> </TableHead>
<SortableTableHead <SortableTableHead
class="h-12 font-semibold w-[16%]" :class="['h-12 font-semibold', isAdmin ? 'w-[14%]' : 'w-[22%]']"
column-key="model" column-key="model"
:sortable="false" :sortable="false"
:filter-active="filterModel !== '__all__'" :filter-active="filterModel !== '__all__'"
@@ -397,7 +397,7 @@
</template> </template>
</SortableTableHead> </SortableTableHead>
<SortableTableHead <SortableTableHead
class="h-12 font-semibold w-[17%]" :class="['h-12 font-semibold', isAdmin ? 'w-[15%]' : 'w-[14%]']"
column-key="api_format" column-key="api_format"
:sortable="false" :sortable="false"
:filter-active="filterApiFormat !== '__all__'" :filter-active="filterApiFormat !== '__all__'"
@@ -415,7 +415,7 @@
</template> </template>
</SortableTableHead> </SortableTableHead>
<SortableTableHead <SortableTableHead
class="h-12 font-semibold w-[6%] text-center" class="h-12 font-semibold w-[10%] text-center"
column-key="status" column-key="status"
:sortable="false" :sortable="false"
align="center" align="center"
@@ -509,7 +509,7 @@
</div> </div>
</TableCell> </TableCell>
<TableCell <TableCell
class="font-medium py-4 w-[16%]" :class="['font-medium py-4', isAdmin ? 'w-[14%]' : 'w-[22%]']"
:title="getModelTooltip(record)" :title="getModelTooltip(record)"
> >
<div <div
@@ -596,7 +596,7 @@
</div> </div>
</TableCell> </TableCell>
<TableCell <TableCell
class="py-4 w-[17%]" :class="['py-4', isAdmin ? 'w-[15%]' : 'w-[14%]']"
:title="getApiFormatTooltip(record)" :title="getApiFormatTooltip(record)"
> >
<!-- 有格式转换或同族格式差异两行显示 --> <!-- 有格式转换或同族格式差异两行显示 -->
@@ -631,7 +631,7 @@
class="text-muted-foreground text-xs" class="text-muted-foreground text-xs"
>-</span> >-</span>
</TableCell> </TableCell>
<TableCell class="text-center py-4 w-[6%]"> <TableCell class="text-center py-4 w-[10%]">
<!-- 优先显示请求状态 --> <!-- 优先显示请求状态 -->
<Badge <Badge
v-if="getDisplayStatus(record) === 'pending'" v-if="getDisplayStatus(record) === 'pending'"

View File

@@ -0,0 +1,272 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
import type { CandidateRecord, RequestTrace } from '@/api/requestTrace'
import HorizontalRequestTimeline from '../HorizontalRequestTimeline.vue'
vi.mock('@/components/ui/card.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'CardStub',
setup(_, { slots }) {
return () => h('section', slots.default?.())
},
}),
}
})
vi.mock('@/components/ui/badge.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'BadgeStub',
setup(_, { slots }) {
return () => h('span', slots.default?.())
},
}),
}
})
vi.mock('@/components/ui/skeleton.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'SkeletonStub',
setup() {
return () => h('div')
},
}),
}
})
vi.mock('../JsonContentPanel.vue', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'JsonContentPanelStub',
setup() {
return () => h('div')
},
}),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
ChevronLeft: Icon,
ChevronRight: Icon,
ExternalLink: Icon,
}
})
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function buildCandidate(overrides: Partial<CandidateRecord> = {}): CandidateRecord {
return {
id: 'cand-1',
request_id: 'req-1',
candidate_index: 0,
retry_index: 0,
provider_id: 'provider-1',
provider_name: 'Provider 1',
key_id: 'key-1',
key_name: 'Key 1',
status: 'failed',
is_cached: false,
created_at: '2026-05-06T12:00:00.000Z',
started_at: '2026-05-06T12:00:00.000Z',
finished_at: '2026-05-06T12:00:01.000Z',
...overrides,
}
}
function buildTrace(candidates: CandidateRecord[]): RequestTrace {
return {
request_id: 'req-1',
total_candidates: candidates.length,
final_status: 'success',
total_latency_ms: 1000,
candidates,
}
}
function mountTimeline(traceData: RequestTrace) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(HorizontalRequestTimeline, {
requestId: traceData.request_id,
traceData,
})
app.mount(root)
mountedApps.push({ app, root })
return root
}
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('HorizontalRequestTimeline', () => {
it('keeps attempted keys visible for ordinary provider groups that are not selected', async () => {
const trace = buildTrace([
buildCandidate({
id: 'provider-a-key-1',
provider_id: 'provider-a',
provider_name: 'Provider A',
key_id: 'key-a-1',
key_name: 'Key A1',
candidate_index: 0,
status: 'failed',
}),
buildCandidate({
id: 'provider-a-key-2',
provider_id: 'provider-a',
provider_name: 'Provider A',
key_id: 'key-a-2',
key_name: 'Key A2',
candidate_index: 1,
status: 'failed',
}),
buildCandidate({
id: 'provider-b-key-1',
provider_id: 'provider-b',
provider_name: 'Provider B',
key_id: 'key-b-1',
key_name: 'Key B1',
candidate_index: 2,
status: 'failed',
}),
buildCandidate({
id: 'provider-b-key-2',
provider_id: 'provider-b',
provider_name: 'Provider B',
key_id: 'key-b-2',
key_name: 'Key B2',
candidate_index: 3,
status: 'success',
}),
])
const root = mountTimeline(trace)
await nextTick()
const subDots = [...root.querySelectorAll<HTMLButtonElement>('.sub-dot')]
expect(subDots).toHaveLength(2)
expect(subDots.map(dot => dot.getAttribute('title'))).toEqual([
'#1 · Key A2 · 失败',
'#3 · Key B2 · 成功',
])
})
it('orders visible candidates by scheduling index and hides unstarted lazy candidates', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-success',
provider_id: 'provider-success',
provider_name: 'Provider Success',
key_id: 'key-success',
key_name: 'Success Key',
candidate_index: 4,
status: 'success',
started_at: '2026-05-06T12:00:04.000Z',
finished_at: '2026-05-06T12:00:05.000Z',
}),
buildCandidate({
id: 'cand-available',
provider_id: 'provider-available',
provider_name: 'Provider Available',
key_id: 'key-available',
key_name: 'Available Key',
candidate_index: 0,
status: 'available',
started_at: undefined,
finished_at: undefined,
}),
buildCandidate({
id: 'cand-skipped',
provider_id: 'provider-skipped',
provider_name: 'Provider Skipped',
key_id: 'key-skipped',
key_name: 'Skipped Key',
candidate_index: 1,
status: 'skipped',
started_at: undefined,
finished_at: undefined,
}),
buildCandidate({
id: 'cand-pending-unstarted',
provider_id: 'provider-pending',
provider_name: 'Provider Pending',
key_id: 'key-pending',
key_name: 'Pending Key',
candidate_index: 2,
status: 'pending',
started_at: undefined,
finished_at: undefined,
}),
buildCandidate({
id: 'cand-failed',
provider_id: 'provider-failed',
provider_name: 'Provider Failed',
key_id: 'key-failed',
key_name: 'Failed Key',
candidate_index: 3,
status: 'failed',
started_at: '2026-05-06T12:00:03.000Z',
finished_at: '2026-05-06T12:00:04.000Z',
}),
])
const root = mountTimeline(trace)
await nextTick()
const labels = [...root.querySelectorAll<HTMLElement>('.node-label')]
.map(label => label.textContent?.trim())
expect(labels).toEqual(['Provider Skipped', 'Provider Failed', 'Provider Success'])
})
it('uses candidate terminal status for node colors instead of overriding with HTTP code', async () => {
const trace = buildTrace([
buildCandidate({
id: 'cand-body-error',
provider_id: 'provider-body-error',
provider_name: 'Provider Body Error',
key_id: 'key-body-error',
key_name: 'Body Error Key',
candidate_index: 0,
status: 'failed',
status_code: 200,
}),
buildCandidate({
id: 'cand-success',
provider_id: 'provider-success',
provider_name: 'Provider Success',
key_id: 'key-success',
key_name: 'Success Key',
candidate_index: 1,
status: 'success',
status_code: 200,
}),
])
const root = mountTimeline(trace)
await nextTick()
const nodeDots = [...root.querySelectorAll<HTMLElement>('.node-dot')]
expect(nodeDots[0].classList.contains('status-failed')).toBe(true)
expect(nodeDots[0].classList.contains('status-success')).toBe(false)
expect(nodeDots[1].classList.contains('status-success')).toBe(true)
})
})

View File

@@ -189,7 +189,7 @@ describe('poolTrace', () => {
expect(isAttemptedCandidate(buildCandidate({ status: 'unused' }))).toBe(false) expect(isAttemptedCandidate(buildCandidate({ status: 'unused' }))).toBe(false)
}) })
it('shows only attempted pool children when attempted nodes exist', () => { it('keeps skipped pool children visible when attempted nodes exist', () => {
const attempts = buildPoolGroupVisibleAttempts([ const attempts = buildPoolGroupVisibleAttempts([
buildCandidate({ buildCandidate({
id: 'cand-skipped', id: 'cand-skipped',
@@ -210,10 +210,10 @@ describe('poolTrace', () => {
}), }),
]) ])
expect(attempts.map(item => item.id)).toEqual(['cand-failed', 'cand-success']) expect(attempts.map(item => item.id)).toEqual(['cand-skipped', 'cand-failed', 'cand-success'])
}) })
it('collapses all-skipped pool nodes to a single provider node', () => { it('keeps all skipped pool children visible', () => {
const attempts = buildPoolGroupVisibleAttempts([ const attempts = buildPoolGroupVisibleAttempts([
buildCandidate({ buildCandidate({
id: 'cand-skipped-1', id: 'cand-skipped-1',
@@ -227,6 +227,6 @@ describe('poolTrace', () => {
}), }),
]) ])
expect(attempts.map(item => item.id)).toEqual(['cand-skipped-2']) expect(attempts.map(item => item.id)).toEqual(['cand-skipped-1', 'cand-skipped-2'])
}) })
}) })

View File

@@ -91,12 +91,19 @@ describe('usage status helpers', () => {
expect(isUsageRecordFailed(record)).toBe(true) expect(isUsageRecordFailed(record)).toBe(true)
}) })
it('treats explicit success status code as authoritative for the timeline', () => { it('prefers terminal request lifecycle status over status code for the timeline', () => {
expect(resolveTimelineFinalStatus({ expect(resolveTimelineFinalStatus({
traceFinalStatus: 'success', traceFinalStatus: 'success',
requestStatus: 'failed', requestStatus: 'failed',
statusCode: 200, statusCode: 200,
})).toBe('success') })).toBe('failed')
})
it('prefers terminal trace status over status code when request lifecycle is absent', () => {
expect(resolveTimelineFinalStatus({
traceFinalStatus: 'failed',
statusCode: 200,
})).toBe('failed')
}) })
it('falls back to request lifecycle status when status code and trace are missing', () => { it('falls back to request lifecycle status when status code and trace are missing', () => {

View File

@@ -68,14 +68,7 @@ export const isAttemptedCandidate = (
export function buildPoolGroupVisibleAttempts( export function buildPoolGroupVisibleAttempts(
attempts: CandidateRecord[], attempts: CandidateRecord[],
): CandidateRecord[] { ): CandidateRecord[] {
if (attempts.length === 0) return [] return attempts.filter(isPoolParticipatedCandidate)
const attempted = attempts.filter(isAttemptedCandidate)
if (attempted.length > 0) {
return attempted
}
return [attempts[attempts.length - 1]]
} }
export const parseTimelineStatus = (value: unknown): CandidateRecord['status'] | null => { export const parseTimelineStatus = (value: unknown): CandidateRecord['status'] | null => {

View File

@@ -272,8 +272,9 @@ export function resolveTimelineFinalStatus(params: {
requestStatus?: RequestStatusLike requestStatus?: RequestStatusLike
statusCode?: number statusCode?: number
}): TimelineFinalStatus { }): TimelineFinalStatus {
if (typeof params.statusCode === 'number') { const requestStatus = mapRequestStatusToTimelineStatus(params.requestStatus)
return params.statusCode >= 200 && params.statusCode < 400 ? 'success' : 'failed' if (requestStatus === 'success' || requestStatus === 'failed' || requestStatus === 'cancelled') {
return requestStatus
} }
const traceStatus = normalizeTimelineFinalStatus(params.traceFinalStatus) const traceStatus = normalizeTimelineFinalStatus(params.traceFinalStatus)
@@ -281,9 +282,8 @@ export function resolveTimelineFinalStatus(params: {
return traceStatus return traceStatus
} }
const requestStatus = mapRequestStatusToTimelineStatus(params.requestStatus) if (typeof params.statusCode === 'number') {
if (requestStatus === 'success' || requestStatus === 'failed' || requestStatus === 'cancelled') { return params.statusCode >= 200 && params.statusCode < 400 ? 'success' : 'failed'
return requestStatus
} }
if (params.hasPendingCandidates) { if (params.hasPendingCandidates) {

View File

@@ -76,34 +76,6 @@
/> />
</div> </div>
</div> </div>
<div
v-if="showCodexStatsModeSwitch"
class="flex items-center"
data-testid="pool-mobile-header-actions"
>
<div
class="group inline-flex min-h-12 flex-col items-center justify-center gap-1 rounded-md border border-border/50 bg-muted/20 px-2.5 py-1.5 text-xs transition-all duration-200 hover:border-primary/40 hover:bg-muted/40"
data-testid="pool-stats-mode-control"
>
<div class="flex items-center gap-1 leading-none">
<span
class="font-medium transition-colors"
:class="!codexCurrentCycleStatsEnabled ? 'text-foreground/90' : 'text-muted-foreground/80'"
>累计</span>
<span class="text-muted-foreground/50 transition-colors group-hover:text-muted-foreground/80">/</span>
<span
class="font-medium transition-colors"
:class="codexCurrentCycleStatsEnabled ? 'text-foreground/90' : 'text-muted-foreground/80'"
>周期</span>
</div>
<Switch
v-model="codexCurrentCycleStatsEnabled"
class="shrink-0"
aria-label="Codex 统计模式"
data-testid="pool-stats-mode-switch"
/>
</div>
</div>
<div <div
v-if="selectedProviderId" v-if="selectedProviderId"
class="flex items-center gap-1" class="flex items-center gap-1"
@@ -259,33 +231,6 @@
v-if="selectedProviderId" v-if="selectedProviderId"
class="h-4 w-px bg-border" class="h-4 w-px bg-border"
/> />
<div
v-if="showCodexStatsModeSwitch"
class="group inline-flex min-h-12 flex-col items-center justify-center gap-1 rounded-md border border-border/50 bg-muted/20 px-2.5 py-1.5 text-xs transition-all duration-200 hover:border-primary/40 hover:bg-muted/40"
data-testid="pool-stats-mode-control"
>
<div class="flex items-center gap-1 leading-none">
<span
class="font-medium transition-colors"
:class="!codexCurrentCycleStatsEnabled ? 'text-foreground/90' : 'text-muted-foreground/80'"
>累计</span>
<span class="text-muted-foreground/50 transition-colors group-hover:text-muted-foreground/80">/</span>
<span
class="font-medium transition-colors"
:class="codexCurrentCycleStatsEnabled ? 'text-foreground/90' : 'text-muted-foreground/80'"
>周期</span>
</div>
<Switch
v-model="codexCurrentCycleStatsEnabled"
class="shrink-0"
aria-label="Codex 统计模式"
data-testid="pool-stats-mode-switch"
/>
</div>
<div
v-if="showCodexStatsModeSwitch"
class="h-4 w-px bg-border"
/>
<Button <Button
v-if="selectedProviderId" v-if="selectedProviderId"
variant="ghost" variant="ghost"
@@ -418,7 +363,21 @@
class="px-2 font-semibold text-center whitespace-nowrap" class="px-2 font-semibold text-center whitespace-nowrap"
:style="{ width: desktopColumnWidths.stats }" :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>
</TableHead> </TableHead>
<SortableTableHead <SortableTableHead
class="font-semibold text-center whitespace-nowrap" class="font-semibold text-center whitespace-nowrap"
@@ -631,41 +590,66 @@
<TableCell class="py-3 px-2 align-middle"> <TableCell class="py-3 px-2 align-middle">
<div <div
v-if="isPoolKeyCycleStatsDisplay(key)" v-if="isPoolKeyCycleStatsDisplay(key)"
class="mx-auto w-[136px] space-y-1.5 text-[10px] leading-4" class="mx-auto w-[188px] text-[10px] leading-4"
data-testid="pool-stats-cycle-groups" data-testid="pool-stats-cycle-groups"
> >
<div <div
v-for="group in getPoolKeyCycleStatsGroups(key)" class="grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1"
:key="`${key.key_id}-${group.code}-desktop-stats`" data-testid="pool-stats-cycle-grid"
:data-testid="`pool-stats-cycle-group-${group.code}`"
> >
<div class="text-[9px] text-muted-foreground/70 font-medium mb-0.5">{{ group.label }}</div> <span aria-hidden="true" />
<div
v-for="metric in group.metrics"
:key="`${group.code}-${metric.key}`"
class="flex items-center justify-between gap-2"
>
<span class="text-muted-foreground">{{ metric.label }}</span>
<span <span
class="tabular-nums text-foreground/90" class="text-center text-[9px] font-semibold text-muted-foreground/80"
:class="metric.missing ? 'text-muted-foreground/80' : ''" data-testid="pool-stats-cycle-group-5h"
:data-testid="`pool-stats-${group.code}-${metric.key}`" >5H</span>
>{{ metric.value }}</span> <span class="text-center text-muted-foreground/50">|</span>
</div> <span
class="text-center text-[9px] font-semibold text-muted-foreground/80"
data-testid="pool-stats-cycle-group-weekly"
></span>
<template
v-for="row in getPoolKeyCycleStatsRows(key)"
:key="`${key.key_id}-${row.key}-desktop-cycle-row`"
>
<span class="text-muted-foreground truncate">{{ row.label }}</span>
<span
class="min-w-0 truncate text-center tabular-nums text-foreground/90"
:class="row.fiveH.missing ? 'text-muted-foreground/80' : ''"
:data-testid="`pool-stats-5h-${row.key}`"
:title="row.fiveH.value"
>{{ row.fiveH.value }}</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
class="min-w-0 truncate text-center tabular-nums text-foreground/90"
:class="row.weekly.missing ? 'text-muted-foreground/80' : ''"
:data-testid="`pool-stats-weekly-${row.key}`"
:title="row.weekly.value"
>{{ row.weekly.value }}</span>
</template>
</div> </div>
</div> </div>
<div <div
v-else v-else
class="grid grid-rows-3 gap-0.5 w-[136px] mx-auto text-[10px] leading-4" class="grid min-h-16 w-[188px] grid-rows-4 gap-0 mx-auto text-[10px] leading-4"
data-testid="pool-stats-account-total" data-testid="pool-stats-account-total"
> >
<div
class="invisible h-4"
aria-hidden="true"
>
-
</div>
<div <div
v-for="metric in getPoolKeyAccountStatsMetrics(key)" v-for="metric in getPoolKeyAccountStatsMetrics(key)"
:key="`${key.key_id}-${metric.key}-account-total`" :key="`${key.key_id}-${metric.key}-account-total`"
class="flex items-center justify-between gap-2" class="grid grid-cols-[64px_124px] items-center"
>
<span class="text-muted-foreground truncate">{{ metric.label }}</span>
<span
class="min-w-0 truncate text-center tabular-nums text-foreground/90"
:title="metric.value"
> >
<span class="text-muted-foreground">{{ metric.label }}</span>
<span class="tabular-nums text-foreground/90">
{{ metric.value }} {{ metric.value }}
</span> </span>
</div> </div>
@@ -702,6 +686,21 @@
> >
<RefreshCw class="w-3.5 h-3.5" /> <RefreshCw class="w-3.5 h-3.5" />
</Button> </Button>
<Button
v-if="canResetCycleStats(key)"
variant="ghost"
size="icon"
class="h-7 w-7 text-muted-foreground hover:text-foreground"
:disabled="resettingCycleKeyId === key.key_id"
title="重置周期统计"
data-testid="pool-reset-cycle-stats"
@click="handleResetCycleStats(key)"
>
<RotateCcw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': resettingCycleKeyId === key.key_id }"
/>
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -869,35 +868,56 @@
<div class="space-y-1 text-center"> <div class="space-y-1 text-center">
<template v-if="isPoolKeyCycleStatsDisplay(key)"> <template v-if="isPoolKeyCycleStatsDisplay(key)">
<div <div
v-for="group in getPoolKeyCycleStatsGroups(key)" class="grid min-h-16 w-[188px] grid-cols-[38px_64px_10px_64px] items-center gap-x-1 text-left"
:key="`${key.key_id}-${group.code}-mobile-stats`" data-testid="pool-mobile-stats-cycle-grid"
class="flex items-start gap-3 text-left"
:data-testid="`pool-mobile-stats-cycle-group-${group.code}`"
> >
<span class="w-10 shrink-0 pt-0.5 text-[10px] font-semibold text-foreground">{{ group.label }}</span> <span aria-hidden="true" />
<div class="min-w-0 flex-1 space-y-0.5">
<div
v-for="metric in group.metrics"
:key="`${group.code}-${metric.key}-mobile`"
class="flex items-center justify-between gap-2"
>
<span class="text-muted-foreground">{{ metric.label }}</span>
<span <span
class="font-medium text-foreground/90 tabular-nums" class="text-center text-[10px] font-semibold text-foreground"
:class="metric.missing ? 'text-muted-foreground/80' : ''" data-testid="pool-mobile-stats-cycle-group-5h"
>{{ metric.value }}</span> >5H</span>
</div> <span class="text-center text-muted-foreground/50">|</span>
</div> <span
class="text-center text-[10px] font-semibold text-foreground"
data-testid="pool-mobile-stats-cycle-group-weekly"
>周</span>
<template
v-for="row in getPoolKeyCycleStatsRows(key)"
:key="`${key.key_id}-${row.key}-mobile-cycle-row`"
>
<span class="text-muted-foreground truncate">{{ row.label }}</span>
<span
class="min-w-0 truncate text-center font-medium text-foreground/90 tabular-nums"
:class="row.fiveH.missing ? 'text-muted-foreground/80' : ''"
:title="row.fiveH.value"
>{{ row.fiveH.value }}</span>
<span class="text-center text-muted-foreground/50">|</span>
<span
class="min-w-0 truncate text-center font-medium text-foreground/90 tabular-nums"
:class="row.weekly.missing ? 'text-muted-foreground/80' : ''"
:title="row.weekly.value"
>{{ row.weekly.value }}</span>
</template>
</div> </div>
</template> </template>
<template v-else> <template v-else>
<div
class="invisible h-4"
aria-hidden="true"
>
-
</div>
<div <div
v-for="metric in getPoolKeyAccountStatsMetrics(key)" v-for="metric in getPoolKeyAccountStatsMetrics(key)"
:key="`${key.key_id}-${metric.key}-mobile-account-total`" :key="`${key.key_id}-${metric.key}-mobile-account-total`"
class="flex items-center justify-between gap-2" class="grid h-4 w-[188px] grid-cols-[64px_124px] items-center text-left"
> >
<span class="text-muted-foreground">{{ metric.label }}</span> <span class="text-muted-foreground truncate">{{ metric.label }}</span>
<span class="font-medium text-foreground/90">{{ metric.value }}</span> <span
class="min-w-0 truncate text-center font-medium text-foreground/90"
:title="metric.value"
>{{ metric.value }}</span>
</div> </div>
</template> </template>
<div class="flex items-center justify-between gap-2 border-t border-border/40 pt-1 mt-1"> <div class="flex items-center justify-between gap-2 border-t border-border/40 pt-1 mt-1">
@@ -1072,6 +1092,20 @@
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<Button
v-else-if="actionId === 'reset_cycle_stats'"
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
:disabled="resettingCycleKeyId === key.key_id"
title="重置周期统计"
@click="handleResetCycleStats(key)"
>
<RotateCcw
class="w-3.5 h-3.5"
:class="{ 'animate-spin': resettingCycleKeyId === key.key_id }"
/>
</Button>
<Button <Button
v-else-if="actionId === 'edit'" v-else-if="actionId === 'edit'"
variant="ghost" variant="ghost"
@@ -1232,6 +1266,8 @@ import {
Copy, Copy,
Shield, Shield,
Globe, Globe,
Repeat2,
RotateCcw,
SquarePen, SquarePen,
Trash2, Trash2,
Users, Users,
@@ -1257,7 +1293,6 @@ import {
SortableTableHead, SortableTableHead,
TableFilterMenu, TableFilterMenu,
TableCell, TableCell,
Switch,
Pagination, Pagination,
Popover, Popover,
PopoverTrigger, PopoverTrigger,
@@ -1282,6 +1317,7 @@ import {
deleteEndpointKey, deleteEndpointKey,
updateProviderKey, updateProviderKey,
refreshProviderQuota, refreshProviderQuota,
resetProviderKeyCycleStats,
} from '@/api/endpoints/keys' } from '@/api/endpoints/keys'
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth' import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
import type { import type {
@@ -1596,13 +1632,7 @@ const selectedProviderType = computed(() => {
return String(fromOverview || '').trim().toLowerCase() return String(fromOverview || '').trim().toLowerCase()
}) })
const showCodexStatsModeSwitch = computed(() => selectedProviderType.value === 'codex') const showCodexStatsModeToggle = computed(() => selectedProviderType.value === 'codex')
const codexCurrentCycleStatsEnabled = computed({
get: () => poolStatsMode.value === 'current_cycle',
set: (enabled: boolean) => {
poolStatsMode.value = enabled ? 'current_cycle' : 'account_total'
},
})
const selectedProviderStatusText = computed(() => { const selectedProviderStatusText = computed(() => {
if (!selectedProviderId.value) return '' if (!selectedProviderId.value) return ''
@@ -1632,9 +1662,9 @@ const showAccountQuotaColumn = computed(() => {
const desktopColumnWidths = computed(() => { const desktopColumnWidths = computed(() => {
if (showAccountQuotaColumn.value) { if (showAccountQuotaColumn.value) {
return { return {
name: '24%', name: '22%',
quota: '21%', quota: '21%',
stats: '13%', stats: '15%',
imported: '10%', imported: '10%',
lastUsed: '9%', lastUsed: '9%',
status: '7%', status: '7%',
@@ -1732,6 +1762,7 @@ const poolStatsMode = ref<PoolManagementStatsMode>(restoredViewState.statsMode)
const hasPoolKeyFilters = computed(() => searchQuery.value.trim().length > 0 || statusFilter.value !== 'all') const hasPoolKeyFilters = computed(() => searchQuery.value.trim().length > 0 || statusFilter.value !== 'all')
const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60 const MANUAL_QUOTA_REFRESH_COOLDOWN_SECONDS = 5 * 60
const refreshingOAuthKeyId = ref<string | null>(null) const refreshingOAuthKeyId = ref<string | null>(null)
const resettingCycleKeyId = ref<string | null>(null)
const savingProxyKeyId = ref<string | null>(null) const savingProxyKeyId = ref<string | null>(null)
const proxyDesktopPopoverOpenKeyId = ref<string | null>(null) const proxyDesktopPopoverOpenKeyId = ref<string | null>(null)
const proxyMobilePopoverOpenKeyId = ref<string | null>(null) const proxyMobilePopoverOpenKeyId = ref<string | null>(null)
@@ -1746,6 +1777,12 @@ const keyFormDialogOpen = ref(false)
const oauthKeyEditDialogOpen = ref(false) const oauthKeyEditDialogOpen = ref(false)
const editingKeyDetail = ref<PoolKeyDetail | null>(null) const editingKeyDetail = ref<PoolKeyDetail | null>(null)
function togglePoolStatsMode() {
poolStatsMode.value = poolStatsMode.value === 'current_cycle'
? 'account_total'
: 'current_cycle'
}
function clearPoolKeyFilters() { function clearPoolKeyFilters() {
if (!hasPoolKeyFilters.value) return if (!hasPoolKeyFilters.value) return
suppressFiltersWatch = true suppressFiltersWatch = true
@@ -1864,6 +1901,20 @@ interface QuotaProgressItem {
updatedAtSeconds?: number | null updatedAtSeconds?: number | null
} }
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 = { type PoolKeyUiState = {
rowClass: string rowClass: string
schedulingBadgeLabel: string schedulingBadgeLabel: string
@@ -1926,6 +1977,7 @@ const keyUiStateMap = computed<Record<string, PoolKeyUiState>>(() => {
mobileActionIds: splitPoolMobileActions({ mobileActionIds: splitPoolMobileActions({
canDownloadOrCopy: true, canDownloadOrCopy: true,
showRefreshToken: showOAuthRefreshControl, showRefreshToken: showOAuthRefreshControl,
canResetCycleStats: canResetCycleStats(key),
canClearCooldown: Boolean(key.cooldown_reason), canClearCooldown: Boolean(key.cooldown_reason),
hasProxy: true, hasProxy: true,
}).primary, }).primary,
@@ -1949,6 +2001,39 @@ function getPoolKeyCycleStatsGroups(key: PoolKeyDetail): PoolCodexCycleStatsGrou
return display.kind === 'codex_cycle' ? display.groups : [] 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[] { function getPoolKeyAccountStatsMetrics(key: PoolKeyDetail): PoolStatsMetric[] {
const display = getPoolKeyStatsDisplay(key) const display = getPoolKeyStatsDisplay(key)
return display.kind === 'account_total' return display.kind === 'account_total'
@@ -1963,6 +2048,10 @@ const quotaRefreshSupported = computed(() => {
|| selectedProviderType.value === 'chatgpt_web' || selectedProviderType.value === 'chatgpt_web'
}) })
function canResetCycleStats(_key: PoolKeyDetail): boolean {
return selectedProviderType.value === 'codex' && Boolean(_key.key_id)
}
const refreshCurrentPageLoading = computed(() => { const refreshCurrentPageLoading = computed(() => {
return keysLoading.value || refreshingCurrentPageQuota.value return keysLoading.value || refreshingCurrentPageQuota.value
}) })
@@ -2519,6 +2608,28 @@ async function clearCooldown(keyId: string) {
} }
} }
async function handleResetCycleStats(key: PoolKeyDetail) {
if (resettingCycleKeyId.value || !canResetCycleStats(key)) return
const confirmed = await confirm({
title: '重置周期统计',
message: `确定要将账号 "${key.key_name || key.key_id.slice(0, 8)}" 5H / 周统计从当前时间重新开始计算吗`,
confirmText: '重置',
})
if (!confirmed) return
resettingCycleKeyId.value = key.key_id
try {
const result = await resetProviderKeyCycleStats(key.key_id)
success(result.message || '周期统计已重置')
await loadKeys()
} catch (err) {
showError(parseApiError(err, '重置周期统计失败'))
} finally {
resettingCycleKeyId.value = null
}
}
async function toggleKeyActive(key: PoolKeyDetail) { async function toggleKeyActive(key: PoolKeyDetail) {
if (togglingKeyId.value) return if (togglingKeyId.value) return
togglingKeyId.value = key.key_id togglingKeyId.value = key.key_id

View File

@@ -17,6 +17,7 @@ const endpointMocks = vi.hoisted(() => ({
deleteEndpointKey: vi.fn(), deleteEndpointKey: vi.fn(),
updateProviderKey: vi.fn(), updateProviderKey: vi.fn(),
refreshProviderQuota: vi.fn(), refreshProviderQuota: vi.fn(),
resetProviderKeyCycleStats: vi.fn(),
refreshProviderOAuth: vi.fn(), refreshProviderOAuth: vi.fn(),
})) }))
@@ -50,6 +51,7 @@ vi.mock('@/api/endpoints/keys', () => ({
deleteEndpointKey: endpointMocks.deleteEndpointKey, deleteEndpointKey: endpointMocks.deleteEndpointKey,
updateProviderKey: endpointMocks.updateProviderKey, updateProviderKey: endpointMocks.updateProviderKey,
refreshProviderQuota: endpointMocks.refreshProviderQuota, refreshProviderQuota: endpointMocks.refreshProviderQuota,
resetProviderKeyCycleStats: endpointMocks.resetProviderKeyCycleStats,
})) }))
vi.mock('@/api/endpoints/provider_oauth', () => ({ vi.mock('@/api/endpoints/provider_oauth', () => ({
@@ -130,6 +132,8 @@ vi.mock('lucide-vue-next', async () => {
Copy: Icon, Copy: Icon,
Shield: Icon, Shield: Icon,
Globe: Icon, Globe: Icon,
Repeat2: Icon,
RotateCcw: Icon,
SquarePen: Icon, SquarePen: Icon,
Trash2: Icon, Trash2: Icon,
Users: Icon, Users: Icon,
@@ -468,11 +472,13 @@ beforeEach(() => {
endpointMocks.deleteEndpointKey.mockReset() endpointMocks.deleteEndpointKey.mockReset()
endpointMocks.updateProviderKey.mockReset() endpointMocks.updateProviderKey.mockReset()
endpointMocks.refreshProviderQuota.mockReset() endpointMocks.refreshProviderQuota.mockReset()
endpointMocks.resetProviderKeyCycleStats.mockReset()
endpointMocks.refreshProviderOAuth.mockReset() endpointMocks.refreshProviderOAuth.mockReset()
endpointMocks.getPoolSchedulingPresets.mockResolvedValue([]) endpointMocks.getPoolSchedulingPresets.mockResolvedValue([])
endpointMocks.clearPoolCooldown.mockResolvedValue({ message: 'ok' }) endpointMocks.clearPoolCooldown.mockResolvedValue({ message: 'ok' })
endpointMocks.refreshProviderQuota.mockResolvedValue({ success: 0, failed: 0 }) endpointMocks.refreshProviderQuota.mockResolvedValue({ success: 0, failed: 0 })
endpointMocks.resetProviderKeyCycleStats.mockResolvedValue({ message: '已重置周期统计', reset_at: 123, windows: 2 })
}) })
afterEach(() => { afterEach(() => {
@@ -483,7 +489,7 @@ afterEach(() => {
}) })
describe('PoolManagement Codex cycle stats mode', () => { describe('PoolManagement Codex cycle stats mode', () => {
it('defaults Codex providers to current-cycle groups and toggles back to account totals', async () => { it('renders Codex current-cycle stats by default with a header icon toggle', async () => {
const codexKey = createPoolKey('codex') const codexKey = createPoolKey('codex')
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] }) endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey)) endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey))
@@ -492,52 +498,96 @@ describe('PoolManagement Codex cycle stats mode', () => {
const root = mountPoolManagement() const root = mountPoolManagement()
await settle() await settle()
const modeSwitch = root.querySelector<HTMLInputElement>('[data-testid="pool-stats-mode-switch"]') expect(root.querySelector('[data-testid="pool-stats-mode-switch"]')).toBeNull()
expect(modeSwitch).not.toBeNull() const modeButton = root.querySelector<HTMLButtonElement>('[data-testid="pool-stats-mode-control"]')
expect(modeSwitch?.checked).toBe(true) 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-5h"]').length).toBeGreaterThan(0)
expect(root.querySelectorAll('[data-testid="pool-stats-cycle-group-weekly"]').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-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-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(endpointMocks.listPoolKeys).toHaveBeenLastCalledWith(
'codex-provider',
expect.objectContaining({
sort_by: 'imported_at',
sort_order: 'desc',
}),
expect.anything(),
)
expect(root.textContent).not.toContain('累计')
expect(root.textContent).not.toContain('总计')
})
if (!modeSwitch) throw new Error('expected stats switch') it('refreshes quota only for keys on the current page', async () => {
modeSwitch.checked = false const pageKeys = [
modeSwitch.dispatchEvent(new Event('change', { bubbles: true })) createPoolKey('codex', { key_id: 'codex-page-key-1', quota_updated_at: null }),
createPoolKey('codex', { key_id: 'codex-page-key-2', quota_updated_at: null }),
]
endpointMocks.getPoolOverview.mockResolvedValue({
items: [{ ...createOverview('codex'), total_keys: 120 }],
})
endpointMocks.listPoolKeys.mockResolvedValue({
total: 120,
page: 1,
page_size: 50,
keys: pageKeys,
})
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
endpointMocks.refreshProviderQuota.mockResolvedValue({
success: 2,
failed: 0,
total: 2,
results: [],
})
const root = mountPoolManagement()
await settle() await settle()
const refreshButton = root.querySelector('button[title="刷新数据和额度"]') as HTMLButtonElement | null
expect(refreshButton).not.toBeNull()
refreshButton?.click()
await settle()
expect(endpointMocks.refreshProviderQuota).toHaveBeenCalledTimes(1)
expect(endpointMocks.refreshProviderQuota).toHaveBeenCalledWith(
'codex-provider',
['codex-page-key-1', 'codex-page-key-2'],
)
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(routeMocks.query.statsMode).toBe('account_total')
expect(window.sessionStorage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)).toContain('"statsMode":"account_total"') expect(window.sessionStorage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)).toContain('"statsMode":"account_total"')
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull() expect(modeButton?.getAttribute('title')).toBe('切换为周期统计')
expect(root.textContent).toContain('9,876')
expect(root.textContent).toContain('4.3M')
expect(root.textContent).toContain('$8.77')
}) })
it('renders the Codex stats switch in header actions instead of a standalone mode bar', async () => { it('restores stored and query account-total mode for Codex providers', 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 desktopHeaderActions = root.querySelector('[data-testid="pool-header-actions"]')
const mobileHeaderActions = root.querySelector('[data-testid="pool-mobile-header-actions"]')
const modeControls = Array.from(root.querySelectorAll('[data-testid="pool-stats-mode-control"]'))
expect(desktopHeaderActions?.querySelector('[data-testid="pool-stats-mode-control"]')).not.toBeNull()
expect(mobileHeaderActions?.querySelector('[data-testid="pool-stats-mode-control"]')).not.toBeNull()
expect(modeControls).toHaveLength(2)
expect(modeControls.every(control => control.closest('[data-testid="pool-header-actions"], [data-testid="pool-mobile-header-actions"]'))).toBe(true)
expect(desktopHeaderActions?.textContent).toContain('累计')
expect(desktopHeaderActions?.textContent).toContain('周期')
expect(root.textContent).not.toContain('Codex 统计模式')
expect(root.textContent).not.toContain('当前周期显示 5H 与周窗口')
})
it('restores stored Codex account-total mode when the query omits statsMode', async () => {
seedStoredStatsMode('account_total') seedStoredStatsMode('account_total')
routeMocks.query.statsMode = 'account_total'
const codexKey = createPoolKey('codex') const codexKey = createPoolKey('codex')
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] }) endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey)) endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey))
@@ -546,18 +596,13 @@ describe('PoolManagement Codex cycle stats mode', () => {
const root = mountPoolManagement() const root = mountPoolManagement()
await settle() await settle()
const modeSwitch = root.querySelector<HTMLInputElement>('[data-testid="pool-stats-mode-switch"]')
expect(modeSwitch).not.toBeNull()
expect(modeSwitch?.checked).toBe(false)
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull() expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull()
expect(root.querySelector('[data-testid="pool-stats-cycle-group-5h"]')).toBeNull() expect(root.querySelector('[data-testid="pool-stats-cycle-group-5h"]')).toBeNull()
expect(routeMocks.query.statsMode).toBe('account_total') expect(routeMocks.query.statsMode).toBe('account_total')
expect(window.sessionStorage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)).toContain('"statsMode":"account_total"') expect(window.sessionStorage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)).toContain('"statsMode":"account_total"')
}) })
it('lets a current-cycle statsMode query override stored Codex account-total mode', async () => { it('resets Codex cycle stats from the action column', async () => {
seedStoredStatsMode('account_total')
routeMocks.query.statsMode = 'current_cycle'
const codexKey = createPoolKey('codex') const codexKey = createPoolKey('codex')
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] }) endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey)) endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey))
@@ -566,13 +611,14 @@ describe('PoolManagement Codex cycle stats mode', () => {
const root = mountPoolManagement() const root = mountPoolManagement()
await settle() await settle()
const modeSwitch = root.querySelector<HTMLInputElement>('[data-testid="pool-stats-mode-switch"]') const resetButton = root.querySelector<HTMLButtonElement>('[data-testid="pool-reset-cycle-stats"]')
expect(modeSwitch).not.toBeNull() expect(resetButton).not.toBeNull()
expect(modeSwitch?.checked).toBe(true)
expect(root.querySelector('[data-testid="pool-stats-cycle-group-5h"]')).not.toBeNull() resetButton?.click()
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).toBeNull() await settle()
expect(routeMocks.query.statsMode).toBeUndefined()
expect(window.sessionStorage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)).toContain('"statsMode":"current_cycle"') expect(endpointMocks.resetProviderKeyCycleStats).toHaveBeenCalledWith(codexKey.key_id)
expect(endpointMocks.listPoolKeys).toHaveBeenCalledTimes(2)
}) })
it('hides the stats mode switch for non-Codex providers and keeps account totals', async () => { it('hides the stats mode switch for non-Codex providers and keeps account totals', async () => {
@@ -590,6 +636,7 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(root.querySelector('[data-testid="pool-stats-mode-switch"]')).toBeNull() 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-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-group-5h"]')).toBeNull()
expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull() expect(root.querySelector('[data-testid="pool-stats-account-total"]')).not.toBeNull()
expect(root.textContent).toContain('12') expect(root.textContent).toContain('12')