use crate::modules::{auth::account, system::logger};
use crate::proxy::admin::runtime::audit;
use crate::proxy::admin::ErrorResponse;
use crate::proxy::state::AdminState;
use axum::{
extract::{Json, Path, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub(crate) struct AccountResponse {
id: String,
email: String,
name: Option<String>,
is_current: bool,
disabled: bool,
disabled_reason: Option<String>,
disabled_at: Option<i64>,
proxy_disabled: bool,
proxy_disabled_reason: Option<String>,
proxy_disabled_at: Option<i64>,
protected_models: Vec<String>,
validation_blocked: bool,
validation_blocked_until: Option<i64>,
validation_blocked_reason: Option<String>,
quota: Option<QuotaResponse>,
device_bound: bool,
last_used: i64,
token_expiry: i64,
}
#[derive(Serialize)]
pub(crate) struct QuotaResponse {
models: Vec<ModelQuota>,
last_updated: i64,
subscription_tier: Option<String>,
is_forbidden: bool,
}
#[derive(Serialize)]
pub(crate) struct ModelQuota {
name: String,
percentage: i32,
reset_time: String,
#[serde(skip_serializing_if = "Option::is_none")]
request_count: Option<u64>,
}
#[derive(Serialize)]
pub(crate) struct AccountListResponse {
accounts: Vec<AccountResponse>,
current_account_id: Option<String>,
}
fn parse_reset_time_to_timestamp(reset_time: &str) -> Option<i64> {
chrono::DateTime::parse_from_rfc3339(reset_time)
.ok()
.map(|dt| dt.timestamp())
}
fn find_request_count_for_model(
quota_model_name: &str,
counts: &[crate::modules::stats::token_stats::ModelRequestCount],
) -> u64 {
let normalized = quota_model_name
.trim_start_matches("models/")
.to_lowercase();
counts
.iter()
.filter(|c| c.model.to_lowercase() == normalized)
.map(|c| c.request_count)
.sum()
}
pub(crate) fn to_account_response(
account: &crate::models::account::Account,
current_id: &Option<String>,
) -> AccountResponse {
let request_counts = account.quota.as_ref().and_then(|q| {
let earliest_reset = q
.models
.iter()
.filter_map(|m| parse_reset_time_to_timestamp(&m.reset_time))
.min();
let since = earliest_reset.unwrap_or(q.last_updated);
crate::modules::stats::token_stats::get_request_counts_since(&account.email, since).ok()
});
AccountResponse {
id: account.id.clone(),
email: account.email.clone(),
name: account.name.clone(),
is_current: current_id.as_ref() == Some(&account.id),
disabled: account.disabled,
disabled_reason: account.disabled_reason.clone(),
disabled_at: account.disabled_at,
proxy_disabled: account.proxy_disabled,
proxy_disabled_reason: account.proxy_disabled_reason.clone(),
proxy_disabled_at: account.proxy_disabled_at,
protected_models: account.protected_models.iter().cloned().collect(),
quota: account.quota.as_ref().map(|q| {
let counts = request_counts.as_deref().unwrap_or(&[]);
QuotaResponse {
models: q
.models
.iter()
.map(|m| ModelQuota {
name: m.name.clone(),
percentage: m.percentage,
reset_time: m.reset_time.clone(),
request_count: Some(find_request_count_for_model(&m.name, counts)),
})
.collect(),
last_updated: q.last_updated,
subscription_tier: q.subscription_tier.clone(),
is_forbidden: q.is_forbidden,
}
}),
device_bound: account.device_profile.is_some(),
last_used: account.last_used,
token_expiry: account.token.expiry_timestamp,
validation_blocked: account.validation_blocked,
validation_blocked_until: account.validation_blocked_until,
validation_blocked_reason: account.validation_blocked_reason.clone(),
}
}
pub(crate) async fn admin_list_accounts(
State(state): State<AdminState>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let accounts = state.core.account_service.list_accounts().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
let current_id = state.core.account_service.get_current_id().ok().flatten();
let account_responses: Vec<AccountResponse> = accounts
.into_iter()
.map(|acc| {
let is_current = current_id.as_ref().map(|id| id == &acc.id).unwrap_or(false);
let token_expiry = acc.token.expiry_timestamp;
let quota = acc.quota.map(|q| QuotaResponse {
models: q
.models
.into_iter()
.map(|m| ModelQuota {
name: m.name,
percentage: m.percentage,
reset_time: m.reset_time,
request_count: None,
})
.collect(),
last_updated: q.last_updated,
subscription_tier: q.subscription_tier,
is_forbidden: q.is_forbidden,
});
AccountResponse {
id: acc.id,
email: acc.email,
name: acc.name,
is_current,
disabled: acc.disabled,
disabled_reason: acc.disabled_reason,
disabled_at: acc.disabled_at,
proxy_disabled: acc.proxy_disabled,
proxy_disabled_reason: acc.proxy_disabled_reason,
proxy_disabled_at: acc.proxy_disabled_at,
protected_models: acc.protected_models.into_iter().collect(),
validation_blocked: acc.validation_blocked,
validation_blocked_until: acc.validation_blocked_until,
validation_blocked_reason: acc.validation_blocked_reason,
quota,
device_bound: acc.device_profile.is_some(),
last_used: acc.last_used,
token_expiry,
}
})
.collect();
Ok(Json(AccountListResponse {
current_account_id: current_id,
accounts: account_responses,
}))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ExportAccountsRequest {
account_ids: Vec<String>,
}
pub(crate) async fn admin_export_accounts(
State(_state): State<AdminState>,
Json(payload): Json<ExportAccountsRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let response = account::export_accounts_by_ids(&payload.account_ids).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
Ok(Json(response))
}
pub(crate) async fn admin_get_current_account(
State(state): State<AdminState>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let current_id = state.core.account_service.get_current_id().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
let response = if let Some(id) = current_id {
let acc = account::load_account(&id).ok();
acc.map(|acc| {
let token_expiry = acc.token.expiry_timestamp;
let quota = acc.quota.map(|q| QuotaResponse {
models: q
.models
.into_iter()
.map(|m| ModelQuota {
name: m.name,
percentage: m.percentage,
reset_time: m.reset_time,
request_count: None,
})
.collect(),
last_updated: q.last_updated,
subscription_tier: q.subscription_tier,
is_forbidden: q.is_forbidden,
});
AccountResponse {
id: acc.id,
email: acc.email,
name: acc.name,
is_current: true,
disabled: acc.disabled,
disabled_reason: acc.disabled_reason,
disabled_at: acc.disabled_at,
proxy_disabled: acc.proxy_disabled,
proxy_disabled_reason: acc.proxy_disabled_reason,
proxy_disabled_at: acc.proxy_disabled_at,
protected_models: acc.protected_models.into_iter().collect(),
validation_blocked: acc.validation_blocked,
validation_blocked_until: acc.validation_blocked_until,
validation_blocked_reason: acc.validation_blocked_reason,
quota,
device_bound: acc.device_profile.is_some(),
last_used: acc.last_used,
token_expiry,
}
})
} else {
None
};
Ok(Json(response))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AddAccountRequest {
refresh_token: String,
}
pub(crate) async fn admin_add_account(
State(state): State<AdminState>,
Json(payload): Json<AddAccountRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let account = state
.core
.account_service
.add_account(&payload.refresh_token)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
if let Err(e) = state.core.token_manager.load_accounts().await {
logger::log_error(&format!(
"[API] Failed to reload accounts after adding: {}",
e
));
}
let current_id = state.core.account_service.get_current_id().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
Ok(Json(to_account_response(&account, ¤t_id)))
}
pub(crate) async fn admin_delete_account(
State(state): State<AdminState>,
Path(account_id): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
state
.core
.account_service
.delete_account(&account_id)
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
if let Err(e) = state.core.token_manager.load_accounts().await {
logger::log_error(&format!(
"[API] Failed to reload accounts after deletion: {}",
e
));
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SwitchRequest {
account_id: String,
}
pub(crate) async fn admin_switch_account(
State(state): State<AdminState>,
Json(payload): Json<SwitchRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
{
let switching = state.runtime.switching.read().await;
if *switching {
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: "Another switch operation is already in progress".to_string(),
}),
));
}
}
{
let mut switching = state.runtime.switching.write().await;
*switching = true;
}
let account_id = payload.account_id.clone();
logger::log_info(&format!("[API] Starting account switch: {}", account_id));
let result = state.core.account_service.switch_account(&account_id).await;
{
let mut switching = state.runtime.switching.write().await;
*switching = false;
}
match result {
Ok(()) => {
logger::log_info(&format!("[API] Account switch successful: {}", account_id));
state.core.token_manager.clear_all_sessions();
if let Err(e) = state.core.token_manager.load_accounts().await {
logger::log_error(&format!(
"[API] Failed to reload accounts after switch: {}",
e
));
}
Ok(StatusCode::OK)
}
Err(e) => {
logger::log_error(&format!("[API] Account switch failed: {}", e));
Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
))
}
}
}
pub(crate) async fn admin_refresh_all_quotas(
headers: HeaderMap,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let confirmed = headers
.get("x-gephyr-confirm-bulk-refresh")
.and_then(|v| v.to_str().ok())
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "confirm"
)
})
.unwrap_or(false);
if !confirmed {
return Err((
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: "Bulk quota refresh touches every active account. Re-send this request with header `x-gephyr-confirm-bulk-refresh: true` only when you explicitly want to run a full refresh.".to_string(),
}),
));
}
logger::log_info("[API] Starting refresh of all account quotas");
let stats = account::refresh_all_quotas_logic().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
Ok(Json(stats))
}
#[derive(Deserialize)]
pub(crate) struct BulkDeleteRequest {
#[serde(rename = "accountIds")]
account_ids: Vec<String>,
}
pub(crate) async fn admin_delete_accounts(
Json(payload): Json<BulkDeleteRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
crate::modules::auth::account::delete_accounts(&payload.account_ids).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
Ok(StatusCode::OK)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReorderRequest {
account_ids: Vec<String>,
}
pub(crate) async fn admin_reorder_accounts(
State(state): State<AdminState>,
Json(payload): Json<ReorderRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
crate::modules::auth::account::reorder_accounts(&payload.account_ids).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
if let Err(e) = state.core.token_manager.load_accounts().await {
logger::log_error(&format!(
"[API] Failed to reload accounts after reorder: {}",
e
));
}
Ok(StatusCode::OK)
}
pub(crate) async fn admin_fetch_account_quota(
Path(account_id): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let mut account = crate::modules::auth::account::load_account(&account_id).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
let quota = crate::modules::auth::account::fetch_quota_with_retry(&mut account)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: e.to_string(),
}),
)
})?;
crate::modules::auth::account::update_account_quota(&account_id, quota.clone()).map_err(
|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
},
)?;
Ok(Json(quota))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ToggleProxyRequest {
enable: bool,
reason: Option<String>,
}
pub(crate) async fn admin_toggle_proxy_status(
State(state): State<AdminState>,
Path(account_id): Path<String>,
Json(payload): Json<ToggleProxyRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
account::toggle_proxy_status(&account_id, payload.enable, payload.reason.as_deref()).map_err(
|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
},
)?;
let _ = state.core.token_manager.reload_account(&account_id).await;
Ok(StatusCode::OK)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LogoutAccountRequest {
#[serde(default = "default_logout_revoke_remote")]
revoke_remote: bool,
#[serde(default)]
delete_local: bool,
}
fn default_logout_revoke_remote() -> bool {
true
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LogoutAllAccountsRequest {
#[serde(default = "default_logout_revoke_remote")]
revoke_remote: bool,
#[serde(default)]
delete_local: bool,
}
#[derive(Serialize)]
pub(crate) struct LogoutAllAccountsResponse {
success: bool,
total: usize,
logged_out: usize,
deleted: usize,
failed: Vec<LogoutAllAccountFailure>,
}
#[derive(Serialize)]
pub(crate) struct LogoutAllAccountFailure {
account_id: String,
email: String,
error: String,
}
pub(crate) async fn admin_logout_account(
State(state): State<AdminState>,
headers: HeaderMap,
Path(account_id): Path<String>,
Json(payload): Json<LogoutAccountRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let actor = audit::resolve_admin_actor(&state, &headers).await;
state
.core
.account_service
.logout_account(&account_id, payload.revoke_remote)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
let mut deleted = false;
if payload.delete_local {
state
.core
.account_service
.delete_account(&account_id)
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
deleted = true;
let _ = state.core.token_manager.load_accounts().await;
} else {
let _ = state.core.token_manager.reload_account(&account_id).await;
}
audit::log_admin_audit(
"logout_account",
&actor,
serde_json::json!({
"account_id": account_id,
"revoke_remote": payload.revoke_remote,
"delete_local": payload.delete_local,
}),
);
Ok(Json(serde_json::json!({
"success": true,
"account_id": account_id,
"revoked_remote": payload.revoke_remote,
"local_cleared": true,
"disabled": true,
"deleted": deleted,
})))
}
pub(crate) async fn admin_logout_all_accounts(
State(state): State<AdminState>,
headers: HeaderMap,
Json(payload): Json<LogoutAllAccountsRequest>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let actor = audit::resolve_admin_actor(&state, &headers).await;
let accounts = state.core.account_service.list_accounts().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e }),
)
})?;
let total = accounts.len();
let mut logged_out = 0usize;
let mut deleted = 0usize;
let mut failed: Vec<LogoutAllAccountFailure> = Vec::new();
for acc in accounts {
let account_id = acc.id.clone();
let email = acc.email.clone();
match state
.core
.account_service
.logout_account(&account_id, payload.revoke_remote)
.await
{
Ok(()) => {
logged_out += 1;
}
Err(e) => {
failed.push(LogoutAllAccountFailure {
account_id,
email,
error: e,
});
continue;
}
}
if payload.delete_local {
if let Err(e) = state.core.account_service.delete_account(&account_id) {
failed.push(LogoutAllAccountFailure {
account_id,
email,
error: e,
});
continue;
}
deleted += 1;
}
}
if let Err(e) = state.core.token_manager.load_accounts().await {
logger::log_error(&format!(
"[API] Failed to reload accounts after logout-all: {}",
e
));
}
audit::log_admin_audit(
"logout_all_accounts",
&actor,
serde_json::json!({
"revoke_remote": payload.revoke_remote,
"delete_local": payload.delete_local,
"total": total,
"logged_out": logged_out,
"deleted": deleted,
"failed_count": failed.len()
}),
);
Ok(Json(LogoutAllAccountsResponse {
success: failed.is_empty(),
total,
logged_out,
deleted,
failed,
}))
}
pub(crate) async fn admin_run_health_check(
State(state): State<AdminState>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
logger::log_info("[API] Running manual account health check");
let summary = state.core.token_manager.run_startup_health_check().await;
Ok(Json(summary))
}