mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
feat: add read-only admin settings
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+2
-1
@@ -7,7 +7,7 @@ use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::{
|
||||
http::{
|
||||
admin::{login, logs},
|
||||
admin::{login, logs, settings},
|
||||
health::health,
|
||||
middleware::attach_request_id,
|
||||
v1::{models, responses},
|
||||
@@ -23,6 +23,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.route("/v1/models", get(models))
|
||||
.route("/admin/login", post(login))
|
||||
.route("/admin/logs", get(logs))
|
||||
.route("/admin/settings", get(settings))
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(from_fn(attach_request_id))
|
||||
|
||||
+79
-1
@@ -1,4 +1,4 @@
|
||||
use std::path::Path;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -8,7 +8,10 @@ pub type ConfigResult<T> = Result<T, config::ConfigError>;
|
||||
pub struct AppConfig {
|
||||
pub server: ServerConfig,
|
||||
pub api: ApiConfig,
|
||||
pub model: ModelConfig,
|
||||
pub auth: AuthConfig,
|
||||
pub quota: QuotaConfig,
|
||||
pub usage_stats: UsageStatsConfig,
|
||||
pub database: DatabaseConfig,
|
||||
pub security: SecurityConfig,
|
||||
pub tls: TlsConfig,
|
||||
@@ -55,6 +58,11 @@ impl AppConfig {
|
||||
"CPRS_HOST" => self.server.host = value.to_string(),
|
||||
"CPRS_PORT" => self.server.port = parse_env(key, value)?,
|
||||
"CPRS_BASE_URL" => self.api.base_url = value.to_string(),
|
||||
"CPRS_DEFAULT_MODEL" => self.model.default_model = value.to_string(),
|
||||
"CPRS_DEFAULT_REASONING_EFFORT" => {
|
||||
self.model.default_reasoning_effort = parse_optional_string(value);
|
||||
}
|
||||
"CPRS_SERVICE_TIER" => self.model.service_tier = parse_optional_string(value),
|
||||
"CPRS_REFRESH_MARGIN_SECONDS" => {
|
||||
self.auth.refresh_margin_seconds = parse_env(key, value)?;
|
||||
}
|
||||
@@ -62,6 +70,22 @@ impl AppConfig {
|
||||
"CPRS_REFRESH_CONCURRENCY" => {
|
||||
self.auth.refresh_concurrency = parse_env(key, value)?;
|
||||
}
|
||||
"CPRS_MAX_CONCURRENT_PER_ACCOUNT" => {
|
||||
self.auth.max_concurrent_per_account = parse_env(key, value)?;
|
||||
}
|
||||
"CPRS_REQUEST_INTERVAL_MS" => {
|
||||
self.auth.request_interval_ms = parse_env(key, value)?;
|
||||
}
|
||||
"CPRS_ROTATION_STRATEGY" => self.auth.rotation_strategy = value.to_string(),
|
||||
"CPRS_QUOTA_REFRESH_INTERVAL_MINUTES" => {
|
||||
self.quota.refresh_interval_minutes = parse_env(key, value)?;
|
||||
}
|
||||
"CPRS_QUOTA_SKIP_EXHAUSTED" => {
|
||||
self.quota.skip_exhausted = parse_env(key, value)?;
|
||||
}
|
||||
"CPRS_USAGE_HISTORY_RETENTION_DAYS" => {
|
||||
self.usage_stats.history_retention_days = parse_optional_env(key, value)?;
|
||||
}
|
||||
"CPRS_DATABASE_URL" => self.database.url = value.to_string(),
|
||||
"CPRS_MASTER_KEY_FILE" => self.security.master_key_file = value.to_string(),
|
||||
"CPRS_API_KEY_PEPPER_FILE" => {
|
||||
@@ -74,6 +98,9 @@ impl AppConfig {
|
||||
"CPRS_LOG_DIR" => self.logging.directory = value.to_string(),
|
||||
"CPRS_LOG_MAX_FILE_BYTES" => self.logging.max_file_bytes = parse_env(key, value)?,
|
||||
"CPRS_LOG_RETENTION_DAYS" => self.logging.retention_days = parse_env(key, value)?,
|
||||
"CPRS_LOGS_ENABLED" => self.logging.enabled = parse_env(key, value)?,
|
||||
"CPRS_LOGS_CAPACITY" => self.logging.capacity = parse_env(key, value)?,
|
||||
"CPRS_LOGS_CAPTURE_BODY" => self.logging.capture_body = parse_env(key, value)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -92,11 +119,43 @@ pub struct ApiConfig {
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct ModelConfig {
|
||||
#[serde(rename = "default")]
|
||||
pub default_model: String,
|
||||
pub default_reasoning_effort: Option<String>,
|
||||
#[serde(rename = "default_service_tier")]
|
||||
pub service_tier: Option<String>,
|
||||
pub aliases: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct AuthConfig {
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_enabled: bool,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: usize,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: String,
|
||||
pub tier_priority: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct QuotaConfig {
|
||||
pub refresh_interval_minutes: u64,
|
||||
pub warning_thresholds: QuotaWarningThresholds,
|
||||
pub skip_exhausted: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct QuotaWarningThresholds {
|
||||
pub primary: Vec<u8>,
|
||||
pub secondary: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct UsageStatsConfig {
|
||||
pub history_retention_days: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
@@ -125,6 +184,9 @@ pub struct LoggingConfig {
|
||||
pub directory: String,
|
||||
pub max_file_bytes: u64,
|
||||
pub retention_days: u64,
|
||||
pub enabled: bool,
|
||||
pub capacity: u32,
|
||||
pub capture_body: bool,
|
||||
}
|
||||
|
||||
fn parse_env<T>(key: &str, value: &str) -> ConfigResult<T>
|
||||
@@ -136,3 +198,19 @@ where
|
||||
.parse::<T>()
|
||||
.map_err(|error| config::ConfigError::Message(format!("invalid {key}: {error}")))
|
||||
}
|
||||
|
||||
fn parse_optional_env<T>(key: &str, value: &str) -> ConfigResult<Option<T>>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
T::Err: std::fmt::Display,
|
||||
{
|
||||
match parse_optional_string(value) {
|
||||
Some(value) => parse_env(key, &value).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_string(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty() && value != "null").then(|| value.to_string())
|
||||
}
|
||||
|
||||
+95
-16
@@ -14,6 +14,7 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
auth::admin_session::verify_admin_password,
|
||||
config::{AppConfig, QuotaWarningThresholds},
|
||||
http::{auth::admin_session_id, middleware::RequestId},
|
||||
pagination::{clamp_limit, Page},
|
||||
state::AppState,
|
||||
@@ -129,6 +130,54 @@ pub struct LoginData {
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminSettingsData {
|
||||
pub default_model: String,
|
||||
pub default_reasoning_effort: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub model_aliases: std::collections::BTreeMap<String, String>,
|
||||
pub refresh_enabled: bool,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: usize,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: String,
|
||||
pub tier_priority: Vec<String>,
|
||||
pub quota_refresh_interval_minutes: u64,
|
||||
pub quota_warning_thresholds: QuotaWarningThresholds,
|
||||
pub quota_skip_exhausted: bool,
|
||||
pub logs_enabled: bool,
|
||||
pub logs_capacity: u32,
|
||||
pub logs_capture_body: bool,
|
||||
pub usage_history_retention_days: Option<u64>,
|
||||
}
|
||||
|
||||
impl AdminSettingsData {
|
||||
fn from_config(config: &AppConfig) -> Self {
|
||||
Self {
|
||||
default_model: config.model.default_model.clone(),
|
||||
default_reasoning_effort: config.model.default_reasoning_effort.clone(),
|
||||
service_tier: config.model.service_tier.clone(),
|
||||
model_aliases: config.model.aliases.clone(),
|
||||
refresh_enabled: config.auth.refresh_enabled,
|
||||
refresh_margin_seconds: config.auth.refresh_margin_seconds,
|
||||
refresh_concurrency: config.auth.refresh_concurrency,
|
||||
max_concurrent_per_account: config.auth.max_concurrent_per_account,
|
||||
request_interval_ms: config.auth.request_interval_ms,
|
||||
rotation_strategy: config.auth.rotation_strategy.clone(),
|
||||
tier_priority: config.auth.tier_priority.clone(),
|
||||
quota_refresh_interval_minutes: config.quota.refresh_interval_minutes,
|
||||
quota_warning_thresholds: config.quota.warning_thresholds.clone(),
|
||||
quota_skip_exhausted: config.quota.skip_exhausted,
|
||||
logs_enabled: config.logging.enabled,
|
||||
logs_capacity: config.logging.capacity,
|
||||
logs_capture_body: config.logging.capture_body,
|
||||
usage_history_retention_days: config.usage_stats.history_retention_days,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
@@ -241,22 +290,8 @@ pub async fn logs(
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match validate_admin_session(pool, &headers).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return AdminResponse::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AdminEnvelope::new(40101, "Admin session required", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Failed to validate admin session", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
let limit = clamp_limit(query.limit.unwrap_or(50));
|
||||
@@ -286,6 +321,30 @@ pub async fn logs(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn settings(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_id): Extension<RequestId>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let request_id = request_id.as_str().to_string();
|
||||
let Some(pool) = state.db() else {
|
||||
return AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Database is not initialized", (), request_id),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
if let Err(response) = require_admin_session(pool, &headers, &request_id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminSettingsData::from_config(state.config()), request_id),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AdminUserRow {
|
||||
id: String,
|
||||
@@ -350,3 +409,23 @@ async fn validate_admin_session(
|
||||
fn admin_session_cookie(headers: &HeaderMap) -> Option<&str> {
|
||||
admin_session_id(headers)
|
||||
}
|
||||
|
||||
async fn require_admin_session(
|
||||
pool: &sqlx::SqlitePool,
|
||||
headers: &HeaderMap,
|
||||
request_id: &str,
|
||||
) -> Result<(), Response> {
|
||||
match validate_admin_session(pool, headers).await {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(AdminResponse::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AdminEnvelope::new(40101, "Admin session required", (), request_id),
|
||||
)
|
||||
.into_response()),
|
||||
Err(_) => Err(AdminResponse::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminEnvelope::new(50001, "Failed to validate admin session", (), request_id),
|
||||
)
|
||||
.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user