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
@@ -2,6 +2,7 @@ mod batch;
mod create;
mod delete;
mod oauth_invalid;
mod reset_cycle_stats;
mod update;
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
@@ -30,6 +31,11 @@ pub(super) async fn maybe_handle(
{
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? {
return Ok(Some(response));
}
@@ -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());
}
}
@@ -11,7 +11,7 @@ use axum::{
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::chatgpt_web::refresh_chatgpt_web_provider_quota_locally;
@@ -138,20 +138,28 @@ pub(super) async fn maybe_handle(
));
};
let mut keys = state
.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() {
let keys = if let Some(selected_key_ids) = selected_key_ids.as_ref() {
if selected_key_ids.is_empty() {
Vec::new()
} else {
let selected = selected_key_ids.iter().cloned().collect::<BTreeSet<_>>();
keys.into_iter()
.filter(|key| selected.contains(&key.id))
let mut by_id = state
.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()
}
} else {
keys.into_iter()
state
.list_provider_catalog_keys_by_provider_ids(std::slice::from_ref(&provider_id))
.await?
.into_iter()
.filter(|key| {
key.is_active
|| key