mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
refactor: align backend names
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -7,7 +7,7 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{platform::http::middleware::RequestId, runtime::state::AppState};
|
||||
use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
|
||||
|
||||
use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
use super::account_service_error;
|
||||
@@ -0,0 +1,42 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
|
||||
|
||||
use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
use super::{admin_account_data_from_stored, validated_account_import_error};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountRequest {
|
||||
pub token: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
let stored = state
|
||||
.services
|
||||
.accounts
|
||||
.import_validated(payload.token, payload.refresh_token)
|
||||
.await
|
||||
.map_err(|error| validated_account_import_error(error, &request_id))?;
|
||||
|
||||
// 手动添加账号的响应只返回可展示元数据,OAuth token 永不回显。
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(admin_account_data_from_stored(stored), request_id),
|
||||
))
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{platform::http::middleware::RequestId, runtime::state::AppState};
|
||||
use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
|
||||
|
||||
use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
use super::account_service_error;
|
||||
@@ -10,23 +10,12 @@ use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
codex::accounts::repository::StoredAccount,
|
||||
platform::http::middleware::RequestId,
|
||||
codex::accounts::repository::StoredAccount, platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
utils::pagination::{clamp_limit, Page},
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
require_admin_session, AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse,
|
||||
};
|
||||
use super::{account_export_ids, account_service_error, account_status_value, AdminAccountData};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountsQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
use super::{account_export_ids, account_service_error, account_status_value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -41,34 +30,6 @@ enum AccountExportFormat {
|
||||
Sub2Api,
|
||||
}
|
||||
|
||||
pub async fn accounts(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<AccountsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
let limit = clamp_limit(query.limit.unwrap_or(50));
|
||||
let page = state
|
||||
.services
|
||||
.accounts
|
||||
.list(query.cursor, limit)
|
||||
.await
|
||||
.map_err(|error| account_service_error(error, &request_id))?;
|
||||
let Page { items, next_cursor } = page;
|
||||
let page = Page {
|
||||
items: items.into_iter().map(AdminAccountData::from).collect(),
|
||||
next_cursor,
|
||||
};
|
||||
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminPageEnvelope::ok(page, limit, request_id),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn export_accounts(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
codex::accounts::service::{AccountProbeResult, HealthCheckError},
|
||||
platform::http::middleware::RequestId,
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
@@ -13,13 +13,13 @@ use crate::{
|
||||
accounts::service::{AccountImportEntry, StoreImportAccountError},
|
||||
gateway::oauth::{default_codex_home, read_cli_auth_from_home},
|
||||
},
|
||||
platform::http::middleware::RequestId,
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
utils::json::{first_string, string_at},
|
||||
};
|
||||
|
||||
use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
use super::{admin_account_data_from_stored, validated_account_import_error};
|
||||
use super::validated_account_import_error;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AccountImportFormat {
|
||||
@@ -52,42 +52,12 @@ pub struct AccountImportData {
|
||||
pub source_format: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountRequest {
|
||||
pub token: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportCliAuthRequest {
|
||||
pub codex_home: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
let stored = state
|
||||
.services
|
||||
.accounts
|
||||
.import_validated(payload.token, payload.refresh_token)
|
||||
.await
|
||||
.map_err(|error| validated_account_import_error(error, &request_id))?;
|
||||
|
||||
// 手动添加账号的响应只返回可展示元数据,OAuth token 永不回显。
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(admin_account_data_from_stored(stored), request_id),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn import_accounts(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
codex::accounts::{model::AccountStatus, service::RefreshAccountError},
|
||||
platform::http::middleware::RequestId,
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
utils::pagination::{clamp_limit, Page},
|
||||
};
|
||||
|
||||
use super::super::{require_admin_session, AdminError, AdminPageEnvelope, AdminResponse};
|
||||
use super::{account_service_error, AdminAccountData};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountsQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn accounts(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<AccountsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
let limit = clamp_limit(query.limit.unwrap_or(50));
|
||||
let page = state
|
||||
.services
|
||||
.accounts
|
||||
.list(query.cursor, limit)
|
||||
.await
|
||||
.map_err(|error| account_service_error(error, &request_id))?;
|
||||
let Page { items, next_cursor } = page;
|
||||
let page = Page {
|
||||
items: items.into_iter().map(AdminAccountData::from).collect(),
|
||||
next_cursor,
|
||||
};
|
||||
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminPageEnvelope::ok(page, limit, request_id),
|
||||
))
|
||||
}
|
||||
@@ -14,9 +14,11 @@ use super::AdminError;
|
||||
pub mod cookies;
|
||||
pub mod create;
|
||||
pub mod delete;
|
||||
pub mod export;
|
||||
pub mod health;
|
||||
pub mod import;
|
||||
pub mod lifecycle;
|
||||
pub mod list;
|
||||
pub mod mutate;
|
||||
pub mod oauth;
|
||||
pub mod quota;
|
||||
|
||||
@@ -24,22 +26,21 @@ pub use cookies::{
|
||||
delete_account_cookies, get_account_cookies, set_account_cookies, AccountCookiesData,
|
||||
DeleteAccountCookiesData, SetAccountCookiesRequest,
|
||||
};
|
||||
pub use create::{
|
||||
create_account, import_accounts, import_cli_auth, AccountImportData, CreateAccountRequest,
|
||||
ImportCliAuthRequest,
|
||||
};
|
||||
pub use create::{create_account, CreateAccountRequest};
|
||||
pub use delete::{
|
||||
batch_delete_accounts, delete_account, BatchDeleteAccountsData, BatchDeleteAccountsRequest,
|
||||
DeleteAccountData,
|
||||
};
|
||||
pub use export::{export_accounts, AccountExportQuery};
|
||||
pub use health::{health_check_accounts, AccountProbeData, HealthCheckData, HealthCheckRequest};
|
||||
pub use list::{accounts, export_accounts, AccountExportQuery, AccountsQuery};
|
||||
pub use mutate::{
|
||||
pub use import::{import_accounts, import_cli_auth, AccountImportData, ImportCliAuthRequest};
|
||||
pub use lifecycle::{
|
||||
batch_update_account_status, refresh_account, reset_account_usage, update_account_label,
|
||||
update_account_status, BatchUpdateAccountStatusData, BatchUpdateAccountStatusRequest,
|
||||
ResetAccountUsageData, UpdateAccountLabelData, UpdateAccountLabelRequest,
|
||||
UpdateAccountStatusData, UpdateAccountStatusRequest,
|
||||
};
|
||||
pub use list::{accounts, AccountsQuery};
|
||||
pub use oauth::{
|
||||
auth_callback, auth_code_relay, auth_device_login, auth_device_poll, auth_login_start,
|
||||
AdminAuthCallbackQuery, AdminAuthCodeRelayData, AdminAuthCodeRelayRequest,
|
||||
@@ -11,10 +11,10 @@ use reqwest::Url;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
admin::auth::service::{AdminAuthOAuthError, AdminAuthPkceExchange},
|
||||
admin::session::service::{AdminAuthOAuthError, AdminAuthPkceExchange},
|
||||
codex::gateway::oauth::{DeviceCode, OAuthError},
|
||||
config::AppConfig,
|
||||
platform::http::middleware::RequestId,
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
AccountQuotaError, AccountQuotaWarning as ServiceAccountQuotaWarning,
|
||||
AccountQuotaWarnings as ServiceAccountQuotaWarnings,
|
||||
},
|
||||
platform::http::middleware::RequestId,
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
use axum::{
|
||||
extract::State, http::HeaderMap, http::StatusCode, response::IntoResponse, Extension, Json,
|
||||
};
|
||||
|
||||
use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
|
||||
|
||||
use super::{api_key_service_error, CreateApiKeyRequest, CreatedClientApiKeyData};
|
||||
use crate::admin::api::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
|
||||
pub async fn create_api_key(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateApiKeyRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
match state.services.api_keys.create(&payload.name).await {
|
||||
Ok(created) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
CreatedClientApiKeyData::new(created.key, created.plaintext),
|
||||
request_id,
|
||||
),
|
||||
)),
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
|
||||
use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
|
||||
|
||||
use super::{
|
||||
api_key_service_error, ApiKeyExportQuery, ClientApiKeyExportData, ClientApiKeyExportEntry,
|
||||
};
|
||||
use crate::admin::api::{
|
||||
account_export_ids, require_admin_session, AdminEnvelope, AdminError, AdminResponse,
|
||||
};
|
||||
|
||||
pub async fn export_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ApiKeyExportQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
let ids = account_export_ids(query.ids.as_deref());
|
||||
let keys = match state.services.api_keys.export(ids).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) => return Err(api_key_service_error(error, request_id)),
|
||||
};
|
||||
|
||||
// 安全边界:本地 cpr_ key 只导出可展示元数据,绝不导出 plaintext、key_hash 或 pepper。
|
||||
let data = ClientApiKeyExportData {
|
||||
source_format: "rustLocalClientApiKeys",
|
||||
rotation_required: true,
|
||||
api_keys: keys
|
||||
.into_iter()
|
||||
.map(ClientApiKeyExportEntry::from)
|
||||
.collect(),
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(data, request_id),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use axum::{
|
||||
extract::State, http::HeaderMap, http::StatusCode, response::IntoResponse, Extension, Json,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
|
||||
|
||||
use super::{api_key_service_error, ClientApiKeyImportData, ImportedClientApiKeyData};
|
||||
use crate::admin::api::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
|
||||
pub async fn import_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
let imported = match state.services.api_keys.import(&payload).await {
|
||||
Ok(imported) => imported,
|
||||
Err(error) => return Err(api_key_service_error(error, request_id)),
|
||||
};
|
||||
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
ClientApiKeyImportData {
|
||||
imported: imported.imported,
|
||||
skipped: imported.skipped,
|
||||
rotated: true,
|
||||
keys: imported
|
||||
.keys
|
||||
.into_iter()
|
||||
.map(ImportedClientApiKeyData::new)
|
||||
.collect(),
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
|
||||
use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
|
||||
|
||||
use super::{
|
||||
api_key_not_found, api_key_service_error, BatchDeleteClientApiKeysData,
|
||||
BatchDeleteClientApiKeysRequest, ClientApiKeyData, DeleteClientApiKeyData,
|
||||
UpdateClientApiKeyLabelRequest, UpdateClientApiKeyStatusData, UpdateClientApiKeyStatusRequest,
|
||||
};
|
||||
use crate::admin::api::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
|
||||
pub async fn batch_delete_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<BatchDeleteClientApiKeysRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
let deleted = match state.services.api_keys.batch_delete(payload.ids).await {
|
||||
Ok(deleted) => deleted,
|
||||
Err(error) => return Err(api_key_service_error(error, request_id)),
|
||||
};
|
||||
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
BatchDeleteClientApiKeysData {
|
||||
deleted: deleted.deleted,
|
||||
not_found: deleted.not_found,
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update_api_key_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Path(key_id): Path<String>,
|
||||
Json(payload): Json<UpdateClientApiKeyLabelRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
// label 是后台显示备注,不能影响 cpr_ key 的 hash、prefix 或启用状态。
|
||||
match state
|
||||
.services
|
||||
.api_keys
|
||||
.update_label(&key_id, payload.label)
|
||||
.await
|
||||
{
|
||||
Ok(Some(key)) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ClientApiKeyData::from(key), request_id),
|
||||
)),
|
||||
Ok(None) => Err(api_key_not_found(request_id)),
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_api_key_status(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Path(key_id): Path<String>,
|
||||
Json(payload): Json<UpdateClientApiKeyStatusRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state
|
||||
.services
|
||||
.api_keys
|
||||
.update_status(key_id, &payload.status)
|
||||
.await
|
||||
{
|
||||
Ok(Some(updated)) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
UpdateClientApiKeyStatusData {
|
||||
id: updated.id,
|
||||
enabled: updated.enabled,
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
)),
|
||||
Ok(None) => Err(api_key_not_found(request_id)),
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_api_key(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Path(key_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.api_keys.delete(&key_id).await {
|
||||
Ok(true) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(DeleteClientApiKeyData { deleted: true }, request_id),
|
||||
)),
|
||||
Ok(false) => Err(api_key_not_found(request_id)),
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
utils::pagination::{clamp_limit, Page},
|
||||
};
|
||||
|
||||
use super::{api_key_service_error, ApiKeysQuery, ClientApiKeyData};
|
||||
use crate::admin::api::{require_admin_session, AdminError, AdminPageEnvelope, AdminResponse};
|
||||
|
||||
pub async fn api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ApiKeysQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
let limit = clamp_limit(query.limit.unwrap_or(50));
|
||||
match state.services.api_keys.list(query.cursor, limit).await {
|
||||
Ok(page) => {
|
||||
let Page { items, next_cursor } = page;
|
||||
let page = Page {
|
||||
items: items.into_iter().map(ClientApiKeyData::from).collect(),
|
||||
next_cursor,
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminPageEnvelope::ok(page, limit, request_id),
|
||||
))
|
||||
}
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
use axum::http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
admin::client_keys::service::{ApiKeyServiceError, ImportedClientApiKey},
|
||||
platform::identity::client_key_repository::StoredClientApiKey,
|
||||
};
|
||||
|
||||
use super::AdminError;
|
||||
|
||||
pub mod create;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod lifecycle;
|
||||
pub mod list;
|
||||
|
||||
pub use create::create_api_key;
|
||||
pub use export::export_api_keys;
|
||||
pub use import::import_api_keys;
|
||||
pub use lifecycle::{
|
||||
batch_delete_api_keys, delete_api_key, update_api_key_label, update_api_key_status,
|
||||
};
|
||||
pub use list::api_keys;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiKeysQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiKeyExportQuery {
|
||||
pub ids: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateApiKeyRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyData {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatedClientApiKeyData {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub plaintext: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateClientApiKeyStatusRequest {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateClientApiKeyLabelRequest {
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateClientApiKeyStatusData {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BatchDeleteClientApiKeysRequest {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BatchDeleteClientApiKeysData {
|
||||
pub deleted: u32,
|
||||
pub not_found: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteClientApiKeyData {
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyExportData {
|
||||
pub source_format: &'static str,
|
||||
pub rotation_required: bool,
|
||||
pub api_keys: Vec<ClientApiKeyExportEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyExportEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyImportData {
|
||||
pub imported: u32,
|
||||
pub skipped: u32,
|
||||
pub rotated: bool,
|
||||
pub keys: Vec<ImportedClientApiKeyData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportedClientApiKeyData {
|
||||
pub source_id: Option<String>,
|
||||
pub source_prefix: Option<String>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub plaintext: String,
|
||||
}
|
||||
|
||||
impl From<StoredClientApiKey> for ClientApiKeyData {
|
||||
fn from(key: StoredClientApiKey) -> Self {
|
||||
Self {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StoredClientApiKey> for ClientApiKeyExportEntry {
|
||||
fn from(key: StoredClientApiKey) -> Self {
|
||||
Self {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CreatedClientApiKeyData {
|
||||
pub(super) fn new(key: StoredClientApiKey, plaintext: String) -> Self {
|
||||
Self {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
plaintext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportedClientApiKeyData {
|
||||
pub(super) fn new(imported: ImportedClientApiKey) -> Self {
|
||||
let ImportedClientApiKey {
|
||||
key,
|
||||
plaintext,
|
||||
source_id,
|
||||
source_prefix,
|
||||
} = imported;
|
||||
Self {
|
||||
source_id,
|
||||
source_prefix,
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
plaintext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn api_key_service_error(error: ApiKeyServiceError, request_id: String) -> AdminError {
|
||||
match error {
|
||||
ApiKeyServiceError::RepositoryUnavailable => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"API key repository is not initialized",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::HasherUnavailable => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"API key hasher is not initialized",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::List => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to list API keys",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Export => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to export API keys",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Import => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to import API key",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Create => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to create API key",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Delete => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to delete API key",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::UpdateLabel => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to update API key label",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::UpdateStatus => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to update API key status",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Verify => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to verify API key",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::InvalidStatus(message) => {
|
||||
AdminError::new(StatusCode::BAD_REQUEST, 40001, message, request_id)
|
||||
}
|
||||
ApiKeyServiceError::EmptyName => AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
"API key name is required",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::EmptyIds => AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
"API key ids are required",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::LabelTooLong => AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
"API key label must be 64 characters or fewer",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::NoImportableKeys => AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
"No importable API keys found",
|
||||
request_id,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn api_key_not_found(request_id: String) -> AdminError {
|
||||
AdminError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
40401,
|
||||
"API key not found",
|
||||
request_id,
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
codex::serving::http::diagnostics::diagnostics_data, platform::http::middleware::RequestId,
|
||||
codex::serving::http::diagnostics::diagnostics_data, platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
|
||||
use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
|
||||
|
||||
use super::log_service_error;
|
||||
use crate::admin::api::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
|
||||
pub async fn log_detail(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Path(log_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.logs.get(&log_id).await {
|
||||
Ok(Some(log)) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(log, request_id),
|
||||
)),
|
||||
Ok(None) => Err(AdminError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
40401,
|
||||
"Log event not found",
|
||||
request_id,
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::codex::events::service::LogServiceError;
|
||||
|
||||
use super::AdminError;
|
||||
|
||||
pub mod detail;
|
||||
pub mod query;
|
||||
pub mod state;
|
||||
|
||||
pub use detail::log_detail;
|
||||
pub use query::logs;
|
||||
pub use state::{clear_logs, logs_state, update_logs_state};
|
||||
|
||||
pub(super) fn log_service_error(error: LogServiceError, request_id: String) -> AdminError {
|
||||
match error {
|
||||
LogServiceError::RepositoryUnavailable => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Event log repository is not initialized",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::List => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to list event logs",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::Get => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to load event log",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::Count => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to count event logs",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::Clear => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to clear event logs",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::Write => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to write event log",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::InvalidCapacity => AdminError::new(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
42201,
|
||||
"logsCapacity must be greater than 0",
|
||||
request_id,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
codex::events::service::LogListFilter, platform::http::request_id::RequestId,
|
||||
runtime::state::AppState, utils::pagination::clamp_limit,
|
||||
};
|
||||
|
||||
use super::log_service_error;
|
||||
use crate::admin::api::{require_admin_session, AdminError, AdminPageEnvelope, AdminResponse};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogsQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub kind: Option<String>,
|
||||
pub level: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub route: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn logs(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<LogsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
let limit = clamp_limit(query.limit.unwrap_or(50));
|
||||
let cursor = query.cursor.clone();
|
||||
match state.services.logs.list(cursor, limit, query.into()).await {
|
||||
Ok(page) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminPageEnvelope::ok(page, limit, request_id),
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LogsQuery> for LogListFilter {
|
||||
fn from(query: LogsQuery) -> Self {
|
||||
Self {
|
||||
kind: non_empty(query.kind),
|
||||
level: non_empty(query.level),
|
||||
request_id: non_empty(query.request_id),
|
||||
account_id: non_empty(query.account_id),
|
||||
route: non_empty(query.route),
|
||||
model: non_empty(query.model),
|
||||
status_code: query.status_code,
|
||||
search: non_empty(query.search),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
codex::events::service::{ClearLogs, LogState, LogStateUpdate},
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
use super::log_service_error;
|
||||
use crate::admin::api::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogStateData {
|
||||
pub enabled: bool,
|
||||
pub capacity: u32,
|
||||
pub capture_body: bool,
|
||||
pub stored_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClearLogsData {
|
||||
pub cleared: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateLogStateRequest {
|
||||
pub enabled: Option<bool>,
|
||||
pub capacity: Option<u32>,
|
||||
pub capture_body: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn logs_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.logs.state().await {
|
||||
Ok(state) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(LogStateData::from(state), request_id),
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_logs_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<UpdateLogStateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.logs.update_state(payload.into()).await {
|
||||
Ok(state) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(LogStateData::from(state), request_id),
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_logs(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.logs.clear().await {
|
||||
Ok(cleared) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ClearLogsData::from(cleared), request_id),
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LogState> for LogStateData {
|
||||
fn from(state: LogState) -> Self {
|
||||
Self {
|
||||
enabled: state.enabled,
|
||||
capacity: state.capacity,
|
||||
capture_body: state.capture_body,
|
||||
stored_count: state.stored_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClearLogs> for ClearLogsData {
|
||||
fn from(cleared: ClearLogs) -> Self {
|
||||
Self {
|
||||
cleared: cleared.cleared,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpdateLogStateRequest> for LogStateUpdate {
|
||||
fn from(request: UpdateLogStateRequest) -> Self {
|
||||
Self {
|
||||
enabled: request.enabled,
|
||||
capacity: request.capacity,
|
||||
capture_body: request.capture_body,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
pub mod accounts;
|
||||
pub mod api_keys;
|
||||
pub mod auth;
|
||||
pub mod client_keys;
|
||||
pub mod diagnostics;
|
||||
pub mod logs;
|
||||
pub mod models;
|
||||
pub mod response;
|
||||
pub mod router;
|
||||
pub mod session;
|
||||
pub mod settings;
|
||||
pub mod usage;
|
||||
|
||||
use self::{
|
||||
accounts::{account_export_ids, account_status_value},
|
||||
auth::require_admin_session,
|
||||
session::require_admin_session,
|
||||
};
|
||||
|
||||
pub use self::{
|
||||
@@ -7,8 +7,8 @@ use axum::{
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
codex::accounts::models::service::{ModelRefreshResult, ModelServiceError},
|
||||
platform::http::middleware::RequestId,
|
||||
codex::models::service::{ModelRefreshResult, ModelServiceError},
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use axum::{
|
||||
routing::{delete, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::runtime::state::AppState;
|
||||
|
||||
use super::{
|
||||
accounts::{
|
||||
account_quota, accounts, auth_callback, auth_code_relay, auth_device_login,
|
||||
auth_device_poll, auth_login_start, batch_delete_accounts, batch_update_account_status,
|
||||
create_account, delete_account, delete_account_cookies, export_accounts,
|
||||
get_account_cookies, health_check_accounts, import_accounts, import_cli_auth,
|
||||
quota_warnings, refresh_account, reset_account_usage, set_account_cookies,
|
||||
update_account_label, update_account_status,
|
||||
},
|
||||
client_keys::{
|
||||
api_keys, batch_delete_api_keys, create_api_key, delete_api_key, export_api_keys,
|
||||
import_api_keys, update_api_key_label, update_api_key_status,
|
||||
},
|
||||
diagnostics::diagnostics,
|
||||
logs::{clear_logs, log_detail, logs, logs_state, update_logs_state},
|
||||
models::refresh_models,
|
||||
session::{auth_logout, auth_status, login},
|
||||
settings::{settings, update_settings},
|
||||
usage::{usage_stats, usage_stats_summary},
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/admin/login", post(login))
|
||||
.route("/api/admin/auth/status", get(auth_status))
|
||||
.route("/api/admin/auth/logout", post(auth_logout))
|
||||
.route("/api/admin/auth/login-start", post(auth_login_start))
|
||||
.route("/api/admin/auth/code-relay", post(auth_code_relay))
|
||||
.route("/api/admin/auth/callback", get(auth_callback))
|
||||
.route("/auth/openai/callback", get(auth_callback))
|
||||
.route("/api/admin/auth/device-login", post(auth_device_login))
|
||||
.route(
|
||||
"/api/admin/auth/device-poll/{device_code}",
|
||||
get(auth_device_poll),
|
||||
)
|
||||
.route("/api/admin/diagnostics", get(diagnostics))
|
||||
.route("/api/admin/logs", get(logs).delete(clear_logs))
|
||||
.route(
|
||||
"/api/admin/logs/state",
|
||||
get(logs_state).patch(update_logs_state),
|
||||
)
|
||||
.route("/api/admin/logs/{log_id}", get(log_detail))
|
||||
.route("/api/admin/settings", get(settings).patch(update_settings))
|
||||
.route("/api/admin/refresh-models", post(refresh_models))
|
||||
.route("/api/admin/usage-stats", get(usage_stats))
|
||||
.route("/api/admin/usage-stats/summary", get(usage_stats_summary))
|
||||
.route("/api/admin/accounts", get(accounts).post(create_account))
|
||||
.route(
|
||||
"/api/admin/accounts/health-check",
|
||||
post(health_check_accounts),
|
||||
)
|
||||
.route("/api/admin/accounts/quota-warnings", get(quota_warnings))
|
||||
.route(
|
||||
"/api/admin/accounts/batch-delete",
|
||||
post(batch_delete_accounts),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/batch-status",
|
||||
post(batch_update_account_status),
|
||||
)
|
||||
.route("/api/admin/accounts/export", get(export_accounts))
|
||||
.route(
|
||||
"/api/admin/accounts/{account_id}/refresh",
|
||||
post(refresh_account),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/{account_id}/reset-usage",
|
||||
post(reset_account_usage),
|
||||
)
|
||||
.route("/api/admin/accounts/{account_id}/quota", get(account_quota))
|
||||
.route(
|
||||
"/api/admin/accounts/{account_id}/cookies",
|
||||
get(get_account_cookies)
|
||||
.post(set_account_cookies)
|
||||
.delete(delete_account_cookies),
|
||||
)
|
||||
.route("/api/admin/accounts/{account_id}", delete(delete_account))
|
||||
.route(
|
||||
"/api/admin/accounts/{account_id}/label",
|
||||
patch(update_account_label),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/{account_id}/status",
|
||||
patch(update_account_status),
|
||||
)
|
||||
.route("/api/admin/accounts/import", post(import_accounts))
|
||||
.route("/api/admin/accounts/import-cli", post(import_cli_auth))
|
||||
.route("/api/admin/api-keys", get(api_keys).post(create_api_key))
|
||||
.route("/api/admin/api-keys/export", get(export_api_keys))
|
||||
.route("/api/admin/api-keys/import", post(import_api_keys))
|
||||
.route(
|
||||
"/api/admin/api-keys/batch-delete",
|
||||
post(batch_delete_api_keys),
|
||||
)
|
||||
.route("/api/admin/api-keys/{key_id}", delete(delete_api_key))
|
||||
.route(
|
||||
"/api/admin/api-keys/{key_id}/label",
|
||||
patch(update_api_key_label),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/api-keys/{key_id}/status",
|
||||
patch(update_api_key_status),
|
||||
)
|
||||
}
|
||||
@@ -10,12 +10,12 @@ use axum::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
admin::auth::service::{
|
||||
admin::session::service::{
|
||||
AdminAuthPoolSummary as ServiceAdminAuthPoolSummary, AdminAuthServiceError,
|
||||
AdminAuthStatus as ServiceAdminAuthStatus, AdminAuthUser as ServiceAdminAuthUser,
|
||||
AdminSessionValidationError,
|
||||
},
|
||||
platform::http::{auth::admin_session_id, middleware::RequestId},
|
||||
platform::http::{auth::admin_session_id, request_id::RequestId},
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
admin::settings::{SettingsServiceError, SettingsUpdate},
|
||||
config::{AppConfig, QuotaWarningThresholds},
|
||||
platform::http::middleware::RequestId,
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
AccountRepositoryError, AccountUsageListRecord, AccountUsageSummary,
|
||||
},
|
||||
codex::usage::service::UsageServiceError,
|
||||
platform::http::middleware::RequestId,
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
utils::pagination::{clamp_limit, Page},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
pub mod service;
|
||||
@@ -2,8 +2,8 @@ use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
platform::identity::{
|
||||
api_key::ApiKeyHasher,
|
||||
api_key_repository::{ClientApiKeyRepository, StoredClientApiKey},
|
||||
client_key::ApiKeyHasher,
|
||||
client_key_repository::{ClientApiKeyRepository, StoredClientApiKey},
|
||||
},
|
||||
utils::{json::first_string, pagination::Page},
|
||||
};
|
||||
@@ -1,519 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
admin::auth::api_key::{ApiKeyServiceError, ImportedClientApiKey},
|
||||
platform::{http::middleware::RequestId, identity::api_key_repository::StoredClientApiKey},
|
||||
runtime::state::AppState,
|
||||
utils::pagination::{clamp_limit, Page},
|
||||
};
|
||||
|
||||
use super::{
|
||||
account_export_ids, require_admin_session, AdminEnvelope, AdminError, AdminPageEnvelope,
|
||||
AdminResponse,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiKeysQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiKeyExportQuery {
|
||||
pub ids: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateApiKeyRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyData {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatedClientApiKeyData {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub plaintext: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateClientApiKeyStatusRequest {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateClientApiKeyLabelRequest {
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateClientApiKeyStatusData {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BatchDeleteClientApiKeysRequest {
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BatchDeleteClientApiKeysData {
|
||||
pub deleted: u32,
|
||||
pub not_found: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeleteClientApiKeyData {
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyExportData {
|
||||
pub source_format: &'static str,
|
||||
pub rotation_required: bool,
|
||||
pub api_keys: Vec<ClientApiKeyExportEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyExportEntry {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientApiKeyImportData {
|
||||
pub imported: u32,
|
||||
pub skipped: u32,
|
||||
pub rotated: bool,
|
||||
pub keys: Vec<ImportedClientApiKeyData>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportedClientApiKeyData {
|
||||
pub source_id: Option<String>,
|
||||
pub source_prefix: Option<String>,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub plaintext: String,
|
||||
}
|
||||
|
||||
impl From<StoredClientApiKey> for ClientApiKeyData {
|
||||
fn from(key: StoredClientApiKey) -> Self {
|
||||
Self {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<StoredClientApiKey> for ClientApiKeyExportEntry {
|
||||
fn from(key: StoredClientApiKey) -> Self {
|
||||
Self {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CreatedClientApiKeyData {
|
||||
fn new(key: StoredClientApiKey, plaintext: String) -> Self {
|
||||
Self {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
plaintext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImportedClientApiKeyData {
|
||||
fn new(imported: ImportedClientApiKey) -> Self {
|
||||
let ImportedClientApiKey {
|
||||
key,
|
||||
plaintext,
|
||||
source_id,
|
||||
source_prefix,
|
||||
} = imported;
|
||||
Self {
|
||||
source_id,
|
||||
source_prefix,
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
label: key.label,
|
||||
prefix: key.prefix,
|
||||
enabled: key.enabled,
|
||||
created_at: key.created_at,
|
||||
last_used_at: key.last_used_at,
|
||||
plaintext,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn api_key_service_error(error: ApiKeyServiceError, request_id: String) -> AdminError {
|
||||
match error {
|
||||
ApiKeyServiceError::RepositoryUnavailable => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"API key repository is not initialized",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::HasherUnavailable => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"API key hasher is not initialized",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::List => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to list API keys",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Export => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to export API keys",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Import => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to import API key",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Create => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to create API key",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Delete => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to delete API key",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::UpdateLabel => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to update API key label",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::UpdateStatus => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to update API key status",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::Verify => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to verify API key",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::InvalidStatus(message) => {
|
||||
AdminError::new(StatusCode::BAD_REQUEST, 40001, message, request_id)
|
||||
}
|
||||
ApiKeyServiceError::EmptyName => AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
"API key name is required",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::EmptyIds => AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
"API key ids are required",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::LabelTooLong => AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
"API key label must be 64 characters or fewer",
|
||||
request_id,
|
||||
),
|
||||
ApiKeyServiceError::NoImportableKeys => AdminError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
"No importable API keys found",
|
||||
request_id,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn api_key_not_found(request_id: String) -> AdminError {
|
||||
AdminError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
40401,
|
||||
"API key not found",
|
||||
request_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ApiKeysQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
let limit = clamp_limit(query.limit.unwrap_or(50));
|
||||
match state.services.api_keys.list(query.cursor, limit).await {
|
||||
Ok(page) => {
|
||||
let Page { items, next_cursor } = page;
|
||||
let page = Page {
|
||||
items: items.into_iter().map(ClientApiKeyData::from).collect(),
|
||||
next_cursor,
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminPageEnvelope::ok(page, limit, request_id),
|
||||
))
|
||||
}
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn export_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<ApiKeyExportQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
let ids = account_export_ids(query.ids.as_deref());
|
||||
let keys = match state.services.api_keys.export(ids).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) => return Err(api_key_service_error(error, request_id)),
|
||||
};
|
||||
|
||||
// 安全边界:本地 cpr_ key 只导出可展示元数据,绝不导出 plaintext、key_hash 或 pepper。
|
||||
let data = ClientApiKeyExportData {
|
||||
source_format: "rustLocalClientApiKeys",
|
||||
rotation_required: true,
|
||||
api_keys: keys
|
||||
.into_iter()
|
||||
.map(ClientApiKeyExportEntry::from)
|
||||
.collect(),
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(data, request_id),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn import_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<Value>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
let imported = match state.services.api_keys.import(&payload).await {
|
||||
Ok(imported) => imported,
|
||||
Err(error) => return Err(api_key_service_error(error, request_id)),
|
||||
};
|
||||
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
ClientApiKeyImportData {
|
||||
imported: imported.imported,
|
||||
skipped: imported.skipped,
|
||||
rotated: true,
|
||||
keys: imported
|
||||
.keys
|
||||
.into_iter()
|
||||
.map(ImportedClientApiKeyData::new)
|
||||
.collect(),
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn create_api_key(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<CreateApiKeyRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
match state.services.api_keys.create(&payload.name).await {
|
||||
Ok(created) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
CreatedClientApiKeyData::new(created.key, created.plaintext),
|
||||
request_id,
|
||||
),
|
||||
)),
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn batch_delete_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<BatchDeleteClientApiKeysRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
let deleted = match state.services.api_keys.batch_delete(payload.ids).await {
|
||||
Ok(deleted) => deleted,
|
||||
Err(error) => return Err(api_key_service_error(error, request_id)),
|
||||
};
|
||||
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
BatchDeleteClientApiKeysData {
|
||||
deleted: deleted.deleted,
|
||||
not_found: deleted.not_found,
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update_api_key_label(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Path(key_id): Path<String>,
|
||||
Json(payload): Json<UpdateClientApiKeyLabelRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
// label 是后台显示备注,不能影响 cpr_ key 的 hash、prefix 或启用状态。
|
||||
match state
|
||||
.services
|
||||
.api_keys
|
||||
.update_label(&key_id, payload.label)
|
||||
.await
|
||||
{
|
||||
Ok(Some(key)) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ClientApiKeyData::from(key), request_id),
|
||||
)),
|
||||
Ok(None) => Err(api_key_not_found(request_id)),
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_api_key_status(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Path(key_id): Path<String>,
|
||||
Json(payload): Json<UpdateClientApiKeyStatusRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state
|
||||
.services
|
||||
.api_keys
|
||||
.update_status(key_id, &payload.status)
|
||||
.await
|
||||
{
|
||||
Ok(Some(updated)) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(
|
||||
UpdateClientApiKeyStatusData {
|
||||
id: updated.id,
|
||||
enabled: updated.enabled,
|
||||
},
|
||||
request_id,
|
||||
),
|
||||
)),
|
||||
Ok(None) => Err(api_key_not_found(request_id)),
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_api_key(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Path(key_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.api_keys.delete(&key_id).await {
|
||||
Ok(true) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(DeleteClientApiKeyData { deleted: true }, request_id),
|
||||
)),
|
||||
Ok(false) => Err(api_key_not_found(request_id)),
|
||||
Err(error) => Err(api_key_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
codex::logs::service::{ClearLogs, LogListFilter, LogServiceError, LogState, LogStateUpdate},
|
||||
platform::http::middleware::RequestId,
|
||||
runtime::state::AppState,
|
||||
utils::pagination::clamp_limit,
|
||||
};
|
||||
|
||||
use super::{require_admin_session, AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogsQuery {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub kind: Option<String>,
|
||||
pub level: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub route: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogStateData {
|
||||
pub enabled: bool,
|
||||
pub capacity: u32,
|
||||
pub capture_body: bool,
|
||||
pub stored_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClearLogsData {
|
||||
pub cleared: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateLogStateRequest {
|
||||
pub enabled: Option<bool>,
|
||||
pub capacity: Option<u32>,
|
||||
pub capture_body: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn logs(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<LogsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
let limit = clamp_limit(query.limit.unwrap_or(50));
|
||||
let cursor = query.cursor.clone();
|
||||
match state.services.logs.list(cursor, limit, query.into()).await {
|
||||
Ok(page) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminPageEnvelope::ok(page, limit, request_id),
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logs_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.logs.state().await {
|
||||
Ok(state) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(LogStateData::from(state), request_id),
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_logs_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Json(payload): Json<UpdateLogStateRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.logs.update_state(payload.into()).await {
|
||||
Ok(state) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(LogStateData::from(state), request_id),
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn log_detail(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
Path(log_id): Path<String>,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.logs.get(&log_id).await {
|
||||
Ok(Some(log)) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(log, request_id),
|
||||
)),
|
||||
Ok(None) => Err(AdminError::new(
|
||||
StatusCode::NOT_FOUND,
|
||||
40401,
|
||||
"Log event not found",
|
||||
request_id,
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn clear_logs(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError> {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
require_admin_session(&state, &headers, &request_id).await?;
|
||||
|
||||
match state.services.logs.clear().await {
|
||||
Ok(cleared) => Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ClearLogsData::from(cleared), request_id),
|
||||
)),
|
||||
Err(error) => Err(log_service_error(error, request_id)),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LogState> for LogStateData {
|
||||
fn from(state: LogState) -> Self {
|
||||
Self {
|
||||
enabled: state.enabled,
|
||||
capacity: state.capacity,
|
||||
capture_body: state.capture_body,
|
||||
stored_count: state.stored_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClearLogs> for ClearLogsData {
|
||||
fn from(cleared: ClearLogs) -> Self {
|
||||
Self {
|
||||
cleared: cleared.cleared,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UpdateLogStateRequest> for LogStateUpdate {
|
||||
fn from(request: UpdateLogStateRequest) -> Self {
|
||||
Self {
|
||||
enabled: request.enabled,
|
||||
capacity: request.capacity,
|
||||
capture_body: request.capture_body,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LogsQuery> for LogListFilter {
|
||||
fn from(query: LogsQuery) -> Self {
|
||||
Self {
|
||||
kind: non_empty(query.kind),
|
||||
level: non_empty(query.level),
|
||||
request_id: non_empty(query.request_id),
|
||||
account_id: non_empty(query.account_id),
|
||||
route: non_empty(query.route),
|
||||
model: non_empty(query.model),
|
||||
status_code: query.status_code,
|
||||
search: non_empty(query.search),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn log_service_error(error: LogServiceError, request_id: String) -> AdminError {
|
||||
match error {
|
||||
LogServiceError::RepositoryUnavailable => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Event log repository is not initialized",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::List => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to list event logs",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::Get => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to load event log",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::Count => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to count event logs",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::Clear => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to clear event logs",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::Write => AdminError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
"Failed to write event log",
|
||||
request_id,
|
||||
),
|
||||
LogServiceError::InvalidCapacity => AdminError::new(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
42201,
|
||||
"logsCapacity must be greater than 0",
|
||||
request_id,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
use axum::{
|
||||
routing::{delete, get, patch, post},
|
||||
Router,
|
||||
};
|
||||
|
||||
use crate::runtime::state::AppState;
|
||||
|
||||
use super::{
|
||||
accounts::{
|
||||
account_quota, accounts, auth_callback, auth_code_relay, auth_device_login,
|
||||
auth_device_poll, auth_login_start, batch_delete_accounts, batch_update_account_status,
|
||||
create_account, delete_account, delete_account_cookies, export_accounts,
|
||||
get_account_cookies, health_check_accounts, import_accounts, import_cli_auth,
|
||||
quota_warnings, refresh_account, reset_account_usage, set_account_cookies,
|
||||
update_account_label, update_account_status,
|
||||
},
|
||||
api_keys::{
|
||||
api_keys, batch_delete_api_keys, create_api_key, delete_api_key, export_api_keys,
|
||||
import_api_keys, update_api_key_label, update_api_key_status,
|
||||
},
|
||||
auth::{auth_logout, auth_status, login},
|
||||
diagnostics::diagnostics,
|
||||
logs::{clear_logs, log_detail, logs, logs_state, update_logs_state},
|
||||
models::refresh_models,
|
||||
settings::{settings, update_settings},
|
||||
usage::{usage_stats, usage_stats_summary},
|
||||
};
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/admin/login", post(login))
|
||||
.route("/admin/auth/status", get(auth_status))
|
||||
.route("/admin/auth/logout", post(auth_logout))
|
||||
.route("/admin/auth/login-start", post(auth_login_start))
|
||||
.route("/admin/auth/code-relay", post(auth_code_relay))
|
||||
.route("/admin/auth/callback", get(auth_callback))
|
||||
// OpenAI OAuth 客户端注册的是 /auth/callback;handler 内仍要求管理员会话。
|
||||
.route("/auth/callback", get(auth_callback))
|
||||
.route("/admin/auth/device-login", post(auth_device_login))
|
||||
.route(
|
||||
"/admin/auth/device-poll/{device_code}",
|
||||
get(auth_device_poll),
|
||||
)
|
||||
.route("/admin/diagnostics", get(diagnostics))
|
||||
.route("/admin/logs", get(logs).delete(clear_logs))
|
||||
.route(
|
||||
"/admin/logs/state",
|
||||
get(logs_state).patch(update_logs_state),
|
||||
)
|
||||
.route("/admin/logs/{log_id}", get(log_detail))
|
||||
.route("/admin/settings", get(settings).patch(update_settings))
|
||||
.route("/admin/refresh-models", post(refresh_models))
|
||||
.route("/admin/usage-stats", get(usage_stats))
|
||||
.route("/admin/usage-stats/summary", get(usage_stats_summary))
|
||||
.route("/admin/accounts", get(accounts).post(create_account))
|
||||
.route("/admin/accounts/health-check", post(health_check_accounts))
|
||||
.route("/admin/accounts/quota-warnings", get(quota_warnings))
|
||||
.route("/admin/accounts/batch-delete", post(batch_delete_accounts))
|
||||
.route(
|
||||
"/admin/accounts/batch-status",
|
||||
post(batch_update_account_status),
|
||||
)
|
||||
.route("/admin/accounts/export", get(export_accounts))
|
||||
.route(
|
||||
"/admin/accounts/{account_id}/refresh",
|
||||
post(refresh_account),
|
||||
)
|
||||
.route(
|
||||
"/admin/accounts/{account_id}/reset-usage",
|
||||
post(reset_account_usage),
|
||||
)
|
||||
.route("/admin/accounts/{account_id}/quota", get(account_quota))
|
||||
.route(
|
||||
"/admin/accounts/{account_id}/cookies",
|
||||
get(get_account_cookies)
|
||||
.post(set_account_cookies)
|
||||
.delete(delete_account_cookies),
|
||||
)
|
||||
.route("/admin/accounts/{account_id}", delete(delete_account))
|
||||
.route(
|
||||
"/admin/accounts/{account_id}/label",
|
||||
patch(update_account_label),
|
||||
)
|
||||
.route(
|
||||
"/admin/accounts/{account_id}/status",
|
||||
patch(update_account_status),
|
||||
)
|
||||
.route("/admin/accounts/import", post(import_accounts))
|
||||
.route("/admin/accounts/import-cli", post(import_cli_auth))
|
||||
.route("/admin/api-keys", get(api_keys).post(create_api_key))
|
||||
.route("/admin/api-keys/export", get(export_api_keys))
|
||||
.route("/admin/api-keys/import", post(import_api_keys))
|
||||
.route("/admin/api-keys/batch-delete", post(batch_delete_api_keys))
|
||||
.route("/admin/api-keys/{key_id}", delete(delete_api_key))
|
||||
.route(
|
||||
"/admin/api-keys/{key_id}/label",
|
||||
patch(update_api_key_label),
|
||||
)
|
||||
.route(
|
||||
"/admin/api-keys/{key_id}/status",
|
||||
patch(update_api_key_status),
|
||||
)
|
||||
}
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod http;
|
||||
pub mod api;
|
||||
pub mod client_keys;
|
||||
pub mod session;
|
||||
pub mod settings;
|
||||
pub mod tasks;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod api_key;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
@@ -5,7 +5,7 @@ use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
admin::auth::repository::AdminAuthRepository,
|
||||
admin::session::repository::AdminAuthRepository,
|
||||
codex::accounts::service::{AccountService, ValidatedAccountImportError},
|
||||
codex::accounts::{
|
||||
model::AccountStatus,
|
||||
@@ -2,7 +2,7 @@ use chrono::Utc;
|
||||
use tokio::time::{interval, Duration};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::admin::auth::repository::AdminAuthRepository;
|
||||
use crate::admin::session::repository::AdminAuthRepository;
|
||||
use crate::runtime::tasks::types::SchedulerHandle;
|
||||
|
||||
/// 会话清理调度器 - 定期删除过期的管理员会话
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
pub mod cf_path_block;
|
||||
pub mod cloudflare_challenge;
|
||||
pub mod cookies;
|
||||
pub mod jwt;
|
||||
pub mod lifecycle;
|
||||
pub mod model;
|
||||
pub mod models;
|
||||
pub mod pool;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
|
||||
@@ -9,13 +9,6 @@ use crate::{
|
||||
utils::pagination::{decode_cursor, encode_cursor, Page},
|
||||
};
|
||||
|
||||
mod lease;
|
||||
mod quota;
|
||||
mod token;
|
||||
mod usage;
|
||||
|
||||
pub use usage::AccountUsageRepository;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AccountRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
@@ -155,8 +148,8 @@ pub struct AccountUsageSummary {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AccountRepository {
|
||||
pool: SqlitePool,
|
||||
secret_box: SecretBox,
|
||||
pub(super) pool: SqlitePool,
|
||||
pub(super) secret_box: SecretBox,
|
||||
}
|
||||
|
||||
const INSERT_ACCOUNT_SQL: &str = r"
|
||||
@@ -644,7 +637,7 @@ impl AccountRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fn status_to_db(status: AccountStatus) -> &'static str {
|
||||
pub(crate) fn status_to_db(status: AccountStatus) -> &'static str {
|
||||
match status {
|
||||
AccountStatus::Active => "active",
|
||||
AccountStatus::Expired => "expired",
|
||||
@@ -667,11 +660,13 @@ fn status_from_db(value: &str) -> AccountRepositoryResult<AccountStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_rfc3339(value: Option<String>) -> AccountRepositoryResult<Option<DateTime<Utc>>> {
|
||||
pub(crate) fn parse_optional_rfc3339(
|
||||
value: Option<String>,
|
||||
) -> AccountRepositoryResult<Option<DateTime<Utc>>> {
|
||||
value.as_deref().map(parse_rfc3339).transpose()
|
||||
}
|
||||
|
||||
fn parse_rfc3339(value: &str) -> AccountRepositoryResult<DateTime<Utc>> {
|
||||
pub(crate) fn parse_rfc3339(value: &str) -> AccountRepositoryResult<DateTime<Utc>> {
|
||||
Ok(DateTime::parse_from_rfc3339(value)?.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
@@ -679,7 +674,7 @@ fn nonnegative_i64_to_u64(value: i64) -> u64 {
|
||||
value.max(0) as u64
|
||||
}
|
||||
|
||||
fn optional_positive_i64_to_u64(value: Option<i64>) -> Option<u64> {
|
||||
pub(crate) fn optional_positive_i64_to_u64(value: Option<i64>) -> Option<u64> {
|
||||
value
|
||||
.and_then(|value| u64::try_from(value).ok())
|
||||
.filter(|value| *value > 0)
|
||||
@@ -0,0 +1,9 @@
|
||||
mod accounts;
|
||||
mod leases;
|
||||
mod quotas;
|
||||
mod tokens;
|
||||
mod usage;
|
||||
|
||||
pub use accounts::*;
|
||||
pub(crate) use accounts::{optional_positive_i64_to_u64, parse_optional_rfc3339, status_to_db};
|
||||
pub use usage::AccountUsageRepository;
|
||||
@@ -185,7 +185,7 @@ mod tests {
|
||||
);
|
||||
let access_token = SecretString::new("access-secret".to_string().into());
|
||||
let refresh_token = SecretString::new("refresh-secret".to_string().into());
|
||||
let expires_at = super::super::parse_rfc3339("2026-06-14T00:00:00Z").unwrap();
|
||||
let expires_at = super::super::accounts::parse_rfc3339("2026-06-14T00:00:00Z").unwrap();
|
||||
|
||||
let write = repository
|
||||
.prepare_token_write(&access_token, Some(&refresh_token), Some(expires_at))
|
||||
@@ -198,6 +198,6 @@ mod tests {
|
||||
write.access_token_expires_at.as_deref(),
|
||||
Some("2026-06-14T00:00:00+00:00")
|
||||
);
|
||||
assert!(super::super::parse_rfc3339(&write.updated_at).is_ok());
|
||||
assert!(super::super::accounts::parse_rfc3339(&write.updated_at).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
repository::{AccountRepository, StoredAccount},
|
||||
},
|
||||
codex::gateway::fingerprint::model::Fingerprint,
|
||||
codex::gateway::transport::client::{
|
||||
codex::gateway::transport::http_client::{
|
||||
build_reqwest_client, CodexBackendClient, CodexClientError, CodexRequestContext,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
runtime_pool::pool_account_from_stored, AccountImportCounts, AccountImportEntry,
|
||||
AccountService, StoreImportAccountError, ValidatedAccountImportError,
|
||||
pool_sync::pool_account_from_stored, AccountImportCounts, AccountImportEntry, AccountService,
|
||||
StoreImportAccountError, ValidatedAccountImportError,
|
||||
};
|
||||
|
||||
enum StoredImportAccount {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::codex::accounts::{model::AccountStatus, repository::AccountRepository};
|
||||
|
||||
use super::{
|
||||
runtime_pool::pool_account_from_stored, AccountService, AccountServiceError,
|
||||
BatchDeleteAccounts, BatchUpdateAccountStatus, UpdateAccountStatus,
|
||||
pool_sync::pool_account_from_stored, AccountService, AccountServiceError, BatchDeleteAccounts,
|
||||
BatchUpdateAccountStatus, UpdateAccountStatus,
|
||||
};
|
||||
|
||||
impl AccountService {
|
||||
@@ -1,10 +1,10 @@
|
||||
pub mod cookies;
|
||||
pub mod health;
|
||||
pub mod import;
|
||||
pub mod mutation;
|
||||
pub mod lifecycle;
|
||||
pub mod pool_sync;
|
||||
pub mod quota;
|
||||
pub mod refresh;
|
||||
pub mod runtime_pool;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
health::skipped_probe_result, runtime_pool::pool_account_from_stored, AccountProbeOutcome,
|
||||
health::skipped_probe_result, pool_sync::pool_account_from_stored, AccountProbeOutcome,
|
||||
AccountProbeResult, AccountService, RefreshAccountError,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod event;
|
||||
pub mod repository;
|
||||
pub mod rotation;
|
||||
pub mod service;
|
||||
@@ -1,7 +1,7 @@
|
||||
use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
codex::logs::event::{EventLevel, EventLog},
|
||||
codex::events::event::{EventLevel, EventLog},
|
||||
utils::pagination::{clamp_limit, decode_cursor, encode_cursor, Page},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
codex::logs::{
|
||||
codex::events::{
|
||||
event::EventLog,
|
||||
repository::{EventLogFilters, EventLogRepository},
|
||||
},
|
||||
@@ -1,6 +1,6 @@
|
||||
pub mod conversation_identity;
|
||||
pub mod fingerprint;
|
||||
pub mod identity;
|
||||
pub mod installation;
|
||||
pub mod installation_id;
|
||||
pub mod oauth;
|
||||
pub mod protocol;
|
||||
pub mod transport;
|
||||
|
||||
@@ -19,7 +19,7 @@ const OPENAI_OAUTH_AUTHORIZE_ENDPOINT: &str = "https://auth.openai.com/oauth/aut
|
||||
const OPENAI_OAUTH_DEVICE_CODE_ENDPOINT: &str = "https://auth.openai.com/oauth/device/code";
|
||||
const OPENAI_OAUTH_TOKEN_ENDPOINT: &str = "https://auth.openai.com/oauth/token";
|
||||
const OAUTH_CALLBACK_PORT: u16 = 1455;
|
||||
const OAUTH_CALLBACK_PATH: &str = "/auth/callback";
|
||||
const OAUTH_CALLBACK_PATH: &str = "/auth/openai/callback";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OAuthConfig {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod cli_import;
|
||||
pub mod client;
|
||||
pub mod codex_cli;
|
||||
pub mod refresh;
|
||||
pub mod token;
|
||||
|
||||
pub use cli_import::*;
|
||||
pub use client::*;
|
||||
pub use codex_cli::*;
|
||||
pub use refresh::*;
|
||||
pub use token::*;
|
||||
|
||||
@@ -11,13 +11,12 @@ use serde_json::{Map, Value};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
codex::accounts::models::catalog::BackendModelEntry,
|
||||
codex::gateway::fingerprint::model::Fingerprint,
|
||||
codex::gateway::transport::{
|
||||
headers::build_ordered_codex_headers,
|
||||
sse::SseError,
|
||||
types::CodexResponsesRequest,
|
||||
usage::{extract_sse_usage, TokenUsage},
|
||||
usage_events::{extract_sse_usage, TokenUsage},
|
||||
websocket::{
|
||||
append_rate_limit_updates, create_response_via_websocket,
|
||||
create_response_via_websocket_stream, create_response_via_websocket_stream_with_pool,
|
||||
@@ -26,6 +25,7 @@ use crate::{
|
||||
SharedRateLimitUpdates, WebSocketSupportError,
|
||||
},
|
||||
},
|
||||
codex::models::catalog::BackendModelEntry,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod client;
|
||||
pub mod headers;
|
||||
pub mod http_client;
|
||||
pub mod rate_limits;
|
||||
pub mod sse;
|
||||
pub mod types;
|
||||
pub mod usage;
|
||||
pub mod usage_events;
|
||||
pub mod websocket;
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
pub mod accounts;
|
||||
pub mod events;
|
||||
pub mod gateway;
|
||||
pub mod logs;
|
||||
pub mod models;
|
||||
pub mod serving;
|
||||
pub mod tasks;
|
||||
pub mod usage;
|
||||
|
||||
@@ -2,7 +2,7 @@ use chrono::Utc;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::codex::accounts::models::catalog::{CodexModelInfo, ModelPlanSnapshot};
|
||||
use crate::codex::models::catalog::{CodexModelInfo, ModelPlanSnapshot};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ModelSnapshotRepositoryError {
|
||||
@@ -3,19 +3,19 @@ use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
codex::accounts::models::{
|
||||
catalog::{ModelCatalog, ModelPlanSnapshot},
|
||||
repository::ModelSnapshotRepository,
|
||||
},
|
||||
codex::accounts::{
|
||||
model::{Account, AccountStatus},
|
||||
pool::AccountPool,
|
||||
repository::AccountRepository,
|
||||
},
|
||||
codex::gateway::fingerprint::model::Fingerprint,
|
||||
codex::gateway::transport::client::{
|
||||
codex::gateway::transport::http_client::{
|
||||
build_reqwest_client, CodexBackendClient, CodexRequestContext,
|
||||
},
|
||||
codex::models::{
|
||||
catalog::{ModelCatalog, ModelPlanSnapshot},
|
||||
repository::ModelSnapshotRepository,
|
||||
},
|
||||
config::AppConfig,
|
||||
};
|
||||
|
||||
@@ -12,15 +12,15 @@ use axum::{
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
codex::accounts::models::catalog::ModelCatalog,
|
||||
codex::accounts::models::service::ModelService,
|
||||
codex::events::event::EventLevel,
|
||||
codex::gateway::protocol::{
|
||||
codex_to_openai::{
|
||||
chat_completion_from_codex_sse, chat_completion_stream_from_codex_sse, openai_error,
|
||||
},
|
||||
openai_to_codex::{translate_chat_to_codex, ChatCompletionRequest},
|
||||
},
|
||||
codex::logs::event::EventLevel,
|
||||
codex::models::catalog::ModelCatalog,
|
||||
codex::models::service::ModelService,
|
||||
codex::serving::dispatch::{
|
||||
classify_upstream_account_retry, no_available_accounts_response,
|
||||
normalize_service_tier_for_upstream, CodexRequestLogContext, CodexUpstreamService,
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::Serialize;
|
||||
use crate::{
|
||||
codex::gateway::{
|
||||
fingerprint::model::Fingerprint,
|
||||
transport::client::{
|
||||
transport::http_client::{
|
||||
build_reqwest_client, CodexBackendClient, CodexClientError, CodexRequestContext,
|
||||
},
|
||||
},
|
||||
|
||||
+1
-1
@@ -7,9 +7,9 @@ use crate::{
|
||||
model::{Account, AccountStatus},
|
||||
repository::TokenUpdate,
|
||||
},
|
||||
codex::events::event::{EventLevel, EventLog},
|
||||
codex::gateway::oauth::{RefreshFailure, TokenPair},
|
||||
codex::gateway::transport::types::CodexResponsesRequest,
|
||||
codex::logs::event::{EventLevel, EventLog},
|
||||
};
|
||||
|
||||
use super::CodexUpstreamDependencies;
|
||||
@@ -7,7 +7,7 @@ use crate::codex::{
|
||||
model::{Account, AccountStatus},
|
||||
pool::AccountAcquireRequest,
|
||||
},
|
||||
gateway::transport::{client::CodexClientError, rate_limits::cooldown_with_jitter},
|
||||
gateway::transport::{http_client::CodexClientError, rate_limits::cooldown_with_jitter},
|
||||
};
|
||||
|
||||
use super::{usage::record_request_attempt, CodexUpstreamDependencies};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
mod account_refresh;
|
||||
pub mod affinity;
|
||||
mod audit;
|
||||
pub mod fallback;
|
||||
mod limits;
|
||||
pub mod refresh;
|
||||
// 上游调度辅助命名为 routing,避免出现 dispatch::dispatch 的模块套娃。
|
||||
pub mod routing;
|
||||
pub mod stream;
|
||||
mod stream_audit;
|
||||
pub mod usage;
|
||||
|
||||
use std::{sync::Arc, time::Instant};
|
||||
@@ -31,26 +31,26 @@ use crate::{
|
||||
pool::{AccountAcquireRequest, AccountPool},
|
||||
repository::AccountRepository,
|
||||
},
|
||||
codex::events::{
|
||||
event::{EventLevel, EventLog},
|
||||
service::LogService,
|
||||
},
|
||||
codex::gateway::conversation_identity::{build_conversation_identity, ensure_prompt_cache_key},
|
||||
codex::gateway::fingerprint::model::Fingerprint,
|
||||
codex::gateway::identity::{build_conversation_identity, ensure_prompt_cache_key},
|
||||
codex::gateway::installation::get_installation_id,
|
||||
codex::gateway::installation_id::get_installation_id,
|
||||
codex::gateway::oauth::TokenRefresher,
|
||||
codex::gateway::protocol::codex_to_openai::openai_error,
|
||||
codex::gateway::transport::{
|
||||
client::{
|
||||
http_client::{
|
||||
build_reqwest_client, CodexBackendClient, CodexBackendStream,
|
||||
CodexBackendWebSocketStream, CodexClientError, CodexRequestContext,
|
||||
},
|
||||
types::CodexResponsesRequest,
|
||||
usage::TokenUsage,
|
||||
usage_events::TokenUsage,
|
||||
websocket::{
|
||||
transport_for_request, CodexTransport, CodexWebSocketError, CodexWebSocketPool,
|
||||
},
|
||||
},
|
||||
codex::logs::{
|
||||
event::{EventLevel, EventLog},
|
||||
service::LogService,
|
||||
},
|
||||
config::AppConfig,
|
||||
};
|
||||
|
||||
@@ -65,13 +65,13 @@ pub(crate) use self::{
|
||||
};
|
||||
|
||||
use self::affinity::{compute_variant_hash, SessionAffinityMap, SessionAffinityRepository};
|
||||
use self::audit::{StreamAudit, WebSocketStreamAudit};
|
||||
use self::fallback::{
|
||||
apply_upstream_account_retry_with_deps, apply_upstream_retry_and_acquire_fallback_with_deps,
|
||||
};
|
||||
use self::stream_audit::{StreamAudit, WebSocketStreamAudit};
|
||||
use self::{
|
||||
account_refresh::refresh_account_after_unauthorized,
|
||||
limits::apply_rate_limit_headers_with_deps,
|
||||
refresh::refresh_account_after_unauthorized,
|
||||
routing::request_domain,
|
||||
stream::{completed_response_metadata, ensure_stream_metadata},
|
||||
usage::{record_empty_response_with_deps, record_usage_with_deps},
|
||||
@@ -200,7 +200,7 @@ impl CodexUpstreamService {
|
||||
request: &CodexResponsesRequest,
|
||||
account: &Account,
|
||||
request_id: &str,
|
||||
) -> Result<crate::codex::gateway::transport::client::CodexBackendResponse, CodexClientError>
|
||||
) -> Result<crate::codex::gateway::transport::http_client::CodexBackendResponse, CodexClientError>
|
||||
{
|
||||
send_codex_request_with_refresh_retry_deps(&self.deps, request, account, request_id).await
|
||||
}
|
||||
@@ -466,7 +466,7 @@ async fn send_codex_request_with_refresh_retry_deps(
|
||||
request: &CodexResponsesRequest,
|
||||
account: &Account,
|
||||
request_id: &str,
|
||||
) -> Result<crate::codex::gateway::transport::client::CodexBackendResponse, CodexClientError> {
|
||||
) -> Result<crate::codex::gateway::transport::http_client::CodexBackendResponse, CodexClientError> {
|
||||
let result = match send_codex_request(deps, request, account, request_id).await {
|
||||
Err(CodexClientError::Upstream {
|
||||
status,
|
||||
@@ -503,7 +503,7 @@ async fn send_codex_request(
|
||||
request: &CodexResponsesRequest,
|
||||
account: &Account,
|
||||
request_id: &str,
|
||||
) -> Result<crate::codex::gateway::transport::client::CodexBackendResponse, CodexClientError> {
|
||||
) -> Result<crate::codex::gateway::transport::http_client::CodexBackendResponse, CodexClientError> {
|
||||
let request_domain = request_domain(&deps.config.api.base_url);
|
||||
let cookie_header = match (deps.cookie_repository.as_ref(), request_domain.as_deref()) {
|
||||
(Some(repo), Some(domain)) => repo.cookie_header(&account.id, domain).await.ok().flatten(),
|
||||
|
||||
@@ -8,11 +8,11 @@ use crate::{
|
||||
codex::gateway::transport::types::CodexResponsesRequest,
|
||||
codex::{
|
||||
accounts::pool::AccountPool,
|
||||
events::event::EventLevel,
|
||||
gateway::transport::{
|
||||
usage::extract_sse_usage,
|
||||
usage_events::extract_sse_usage,
|
||||
websocket::{append_rate_limit_updates, CodexWebSocketError, SharedRateLimitUpdates},
|
||||
},
|
||||
logs::event::EventLevel,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::{
|
||||
codex::accounts::repository::UsageDelta, codex::gateway::transport::usage::TokenUsage,
|
||||
codex::accounts::repository::UsageDelta, codex::gateway::transport::usage_events::TokenUsage,
|
||||
};
|
||||
|
||||
use super::CodexUpstreamDependencies;
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::{
|
||||
codex::gateway::protocol::{
|
||||
codex_to_openai::openai_error, openai_to_codex::ChatCompletionRequest,
|
||||
},
|
||||
platform::http::middleware::RequestId,
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
admin::auth::service::AdminAuthPoolSummary,
|
||||
admin::session::service::AdminAuthPoolSummary,
|
||||
codex::{accounts::pool::AccountCapacitySummary, gateway::fingerprint::model::Fingerprint},
|
||||
config::AppConfig,
|
||||
platform::http::middleware::RequestId,
|
||||
platform::http::request_id::RequestId,
|
||||
runtime::state::AppState,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
codex::gateway::protocol::codex_to_openai::openai_error,
|
||||
codex::gateway::transport::client::CodexClientError,
|
||||
codex::gateway::transport::http_client::CodexClientError,
|
||||
};
|
||||
|
||||
pub(crate) fn codex_client_error_response(error: CodexClientError) -> (StatusCode, Json<Value>) {
|
||||
|
||||
@@ -6,7 +6,7 @@ use axum::{
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::{codex::accounts::models::catalog::ModelCatalog, runtime::state::AppState};
|
||||
use crate::{codex::models::catalog::ModelCatalog, runtime::state::AppState};
|
||||
|
||||
use super::{
|
||||
auth::authorize_client_api_key,
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
Extension,
|
||||
};
|
||||
|
||||
use crate::{platform::http::middleware::RequestId, runtime::state::AppState};
|
||||
use crate::{platform::http::request_id::RequestId, runtime::state::AppState};
|
||||
|
||||
use super::{auth::authorize_client_api_key, errors::missing_client_api_key_response};
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
codex::accounts::models::service::ModelService,
|
||||
codex::events::event::EventLevel,
|
||||
codex::gateway::fingerprint::model::Fingerprint,
|
||||
codex::gateway::protocol::codex_to_openai::openai_error,
|
||||
codex::gateway::transport::types::CodexResponsesRequest,
|
||||
codex::logs::event::EventLevel,
|
||||
codex::models::service::ModelService,
|
||||
codex::serving::dispatch::{
|
||||
affinity::SessionAffinityRepositoryResult, classify_upstream_account_retry,
|
||||
completed_response_json, no_available_accounts_response,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod model;
|
||||
pub mod quota;
|
||||
pub mod refresh;
|
||||
pub mod model_refresh;
|
||||
pub mod quota_refresh;
|
||||
pub mod token_refresh;
|
||||
|
||||
@@ -3,9 +3,7 @@ use std::time::Duration;
|
||||
use tokio::time::{interval, sleep};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
codex::accounts::models::service::ModelService, runtime::tasks::types::SchedulerHandle,
|
||||
};
|
||||
use crate::{codex::models::service::ModelService, runtime::tasks::types::SchedulerHandle};
|
||||
|
||||
/// 模型刷新调度器 - 定期从 Codex 后端刷新模型列表
|
||||
///
|
||||
@@ -78,7 +76,7 @@ impl ModelRefresher {
|
||||
// 如果是 NoAccounts 错误,跳过重试直接进入定期刷新
|
||||
let is_no_accounts = matches!(
|
||||
e,
|
||||
crate::codex::accounts::models::service::ModelServiceError::NoAccounts
|
||||
crate::codex::models::service::ModelServiceError::NoAccounts
|
||||
);
|
||||
|
||||
if is_no_accounts {
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod admin;
|
||||
pub mod codex;
|
||||
pub mod runtime;
|
||||
pub mod web;
|
||||
|
||||
pub mod config;
|
||||
pub mod platform;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
use codex_proxy_rs::{
|
||||
codex::logs::rotation::{init_tracing, RotationConfig},
|
||||
config::AppConfig,
|
||||
platform::logging::rotation::{init_tracing, RotationConfig},
|
||||
runtime::{bootstrap::build_state, build_router, tasks::start_background_tasks},
|
||||
};
|
||||
use tokio::signal;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod secret_box;
|
||||
|
||||
pub use secret_box::{CryptoError, CryptoResult, SecretBox};
|
||||
@@ -1,3 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod health;
|
||||
pub mod middleware;
|
||||
pub mod request_id;
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
platform::identity::api_key::{ApiKeyHasher, GeneratedClientApiKey},
|
||||
platform::identity::client_key::{ApiKeyHasher, GeneratedClientApiKey},
|
||||
utils::pagination::{decode_cursor, encode_cursor, Page},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod admin_session;
|
||||
pub mod api_key;
|
||||
pub mod api_key_repository;
|
||||
pub mod client_key;
|
||||
pub mod client_key_repository;
|
||||
pub mod error;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pub mod rotation;
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod crypto;
|
||||
pub mod http;
|
||||
pub mod identity;
|
||||
pub mod logging;
|
||||
pub mod storage;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user