mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: add read-only admin settings
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -3,10 +3,31 @@ server:
|
||||
port: 8080
|
||||
api:
|
||||
base_url: https://chatgpt.com/backend-api
|
||||
model:
|
||||
default: gpt-5.5
|
||||
default_reasoning_effort: null
|
||||
default_service_tier: null
|
||||
aliases: {}
|
||||
auth:
|
||||
refresh_margin_seconds: 300
|
||||
refresh_enabled: true
|
||||
refresh_concurrency: 2
|
||||
max_concurrent_per_account: 3
|
||||
request_interval_ms: 50
|
||||
rotation_strategy: least_used
|
||||
tier_priority: []
|
||||
quota:
|
||||
refresh_interval_minutes: 5
|
||||
warning_thresholds:
|
||||
primary:
|
||||
- 80
|
||||
- 90
|
||||
secondary:
|
||||
- 80
|
||||
- 90
|
||||
skip_exhausted: true
|
||||
usage_stats:
|
||||
history_retention_days: null
|
||||
database:
|
||||
url: sqlite://data/codex-proxy-rs.sqlite
|
||||
security:
|
||||
@@ -20,3 +41,6 @@ logging:
|
||||
directory: logs
|
||||
max_file_bytes: 10485760
|
||||
retention_days: 14
|
||||
enabled: false
|
||||
capacity: 2000
|
||||
capture_body: false
|
||||
|
||||
+37
@@ -67,6 +67,43 @@ Set-Cookie: cpr_admin_session=...; Path=/; HttpOnly; SameSite=Lax; Max-Age=3600
|
||||
|
||||
Invalid password response uses HTTP `401` and body code `40102`.
|
||||
|
||||
### `GET /admin/settings`
|
||||
|
||||
Returns the in-scope runtime settings visible to the admin UI. This endpoint is read-only; persistent settings mutation will be added separately and must use admin sessions, not client API keys.
|
||||
|
||||
Success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"defaultModel": "gpt-5.5",
|
||||
"defaultReasoningEffort": null,
|
||||
"serviceTier": null,
|
||||
"modelAliases": {},
|
||||
"refreshEnabled": true,
|
||||
"refreshMarginSeconds": 300,
|
||||
"refreshConcurrency": 2,
|
||||
"maxConcurrentPerAccount": 3,
|
||||
"requestIntervalMs": 50,
|
||||
"rotationStrategy": "least_used",
|
||||
"tierPriority": [],
|
||||
"quotaRefreshIntervalMinutes": 5,
|
||||
"quotaWarningThresholds": {
|
||||
"primary": [80, 90],
|
||||
"secondary": [80, 90]
|
||||
},
|
||||
"quotaSkipExhausted": true,
|
||||
"logsEnabled": false,
|
||||
"logsCapacity": 2000,
|
||||
"logsCaptureBody": false,
|
||||
"usageHistoryRetentionDays": null
|
||||
},
|
||||
"requestId": "req_01"
|
||||
}
|
||||
```
|
||||
|
||||
Success body:
|
||||
|
||||
```json
|
||||
|
||||
@@ -6,7 +6,7 @@ Update this file before each feature commit.
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Scaffold | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | Rust crate, lint policy, pinned TLS-sensitive dependencies, `.gitignore`, and minimal entry points are in place. |
|
||||
| API contract docs | Completed | `fc47967` | `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | `/v1/*` OpenAI-compatible body, `/admin/*` frontend envelope, body codes, camelCase, and request ID policy are documented. |
|
||||
| Configuration | Completed | `9ed8579` + `e0680bf` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_default_local_and_cprs_env_overrides`; full `fmt/test/clippy` before commit | Defines Codex-only config, default/local/env loading, default YAML, and `Arc<AppServices>` state shell. |
|
||||
| Configuration | Completed | `9ed8579` + `e0680bf` + `pending` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_default_local_and_cprs_env_overrides`; full `fmt/test/clippy` before commit | Defines Codex-only config, default/local/env loading, default YAML, `Arc<AppServices>` state shell, `gpt-5.5` default model, and in-scope model/auth/quota/usage/logging settings fields without proxy/Ollama/provider/OpenAI-key knobs. |
|
||||
| SQLite storage | Completed | `ffec8b5` + `b0c10f2` | `cargo test sqlite_schema_creates_accounts_and_event_tables`; `cargo test connect_sqlite_creates_parent_directory_for_file_database`; full `fmt/test/clippy` before commit | Creates SQLite WAL connector, parent directory bootstrap, sqlx-managed schema for accounts/API keys/sessions/cookies/fingerprints/events, and event/account indexes. |
|
||||
| Admin auth and client API keys | Completed | `24d5799` + `4e0d1e5` | `cargo test client_api_key_has_proxy_prefix_and_verifies_against_hash`; `cargo test admin_password_hash_is_not_a_client_api_key`; `cargo test --test admin_login_route_test`; full `fmt/test/clippy` before commit | Admin passwords use Argon2id, client API keys use `cpr_` prefix plus HMAC-SHA256 with server-side pepper; `/admin/login` issues HttpOnly sessions and rejects client API keys as admin credentials. |
|
||||
| Secret encryption | Completed | `105aa58` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1:<nonce>:<ciphertext>` without plaintext. |
|
||||
@@ -16,7 +16,7 @@ Update this file before each feature commit.
|
||||
| Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` | `cargo test account_pool_skips_expired_disabled_banned_and_quota_exhausted_accounts`; `cargo test --test account_pool_scheduling_test`; `cargo test --test account_refresh_test`; full `fmt/test/clippy` before commit | Implements base account status filtering, refresh scheduler semantics, max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, quota-cache filtering, Cloudflare cooldown skip, request staggering metadata, preferred account selection, exclude IDs, and capacity summary. Still missing settings-backed scheduling config, SQLite repository integration for pool snapshots/release usage, upstream retry/fallback wiring, and admin account CRUD. |
|
||||
| Account repository | Completed | `14fe706` | `cargo test --test account_repository_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds encrypted SQLite account token storage, decrypting reads, cursor pagination, status/label updates, release-time usage counter persistence, and refresh-token-preserving token updates. |
|
||||
| Translation | Completed | `eff182e` | `cargo test chat_completion_translates_to_codex_response_request`; full `fmt/test/clippy` before commit | Adds OpenAI Chat request types, Codex Responses request type, translation, and OpenAI-compatible error body helper. |
|
||||
| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, and `gpt-5.5` as the default/test model baseline. Still missing admin auth middleware extraction, account/auth/API-key management routes, settings routes, diagnostics, usage stats, model catalog/detail/refresh routes, and additional Responses routes. |
|
||||
| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `pending` | `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test models_route_returns_openai_compatible_codex_model_list`; `cargo test --test admin_settings_route_test`; full `fmt/test/clippy` before commit | Adds Axum router, health route, `/v1/models`, `/v1` unauthorized OpenAI error body, global request-id response header, binary listener, `gpt-5.5` as the default/test model baseline, and read-only `/admin/settings` with admin-session auth and lower camelCase envelope. Still missing admin account/auth/API-key management routes, settings mutation/persistence, diagnostics, usage stats, model catalog/detail/refresh routes, and additional Responses routes. |
|
||||
| Admin/frontend response contract | Completed | `1c34ea7` + `b0c10f2` | `cargo test admin_envelope_serializes_lower_camel_case_request_id`; `cargo test admin_logs_reject_missing_admin_session_cookie`; `cargo test v1_response_has_request_id_header_without_admin_body_field`; full `fmt/test/clippy` before commit | Adds reusable admin envelopes with body codes, lower camelCase JSON, paginated list shape, requestId bodies, admin session cookie enforcement, and keeps `/v1/*` OpenAI-compatible. |
|
||||
| HTTP auth boundary | Completed | `cec4607` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_logs_route_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test` | Adds a real `src/http/auth.rs` boundary for client API key and admin session cookie parsing. `/v1/*` accepts only `Bearer cpr_...`, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. |
|
||||
| Upstream lifecycle | Partial | `88cad87` + `225fbff` | `cargo test responses_route_rejects_non_codex_provider_models`; `cargo test v1_requires_client_api_key_not_admin_cookie`; `cargo test --test codex_sse_usage_test`; full `fmt/test/clippy` before commit | `/v1` currently enforces client API key shape, rejects non-Codex provider models, returns OpenAI-compatible errors, and includes reusable SSE/usage helpers. It does not yet acquire accounts, forward to Codex, replay/capture Cookies, stream/collect upstream responses, capture rate-limit headers, retry across accounts, record usage, or preserve previous-response/session affinity. |
|
||||
|
||||
@@ -2960,6 +2960,7 @@ git commit -m "docs: document codex-proxy-rs scope"
|
||||
- [x] Add a tested Codex WebSocket boundary. `src/codex/websocket.rs` classifies HTTP SSE versus WebSocket-only requests, adds `previous_response_id`/`use_websocket` request fields, and returns typed errors instead of silently downgrading WebSocket-required traffic.
|
||||
- [x] Extend account scheduling primitives against the original TypeScript lifecycle. `AccountPool` now covers max concurrent slots, stale slot cleanup, least-used/round-robin/sticky strategies, tier priority, model-plan filtering, cached quota skip, exclude IDs, preferred account selection for session affinity, Cloudflare cooldown skip, request-staggering metadata, and capacity summary.
|
||||
- [x] Set `gpt-5.5` as the default/test model baseline across code, tests, and plan examples. `/v1/models` now asserts the exact default model instead of only checking for a `gpt` prefix.
|
||||
- [x] Add read-only `/admin/settings` for the retained Rust settings surface. The response is admin-session gated, lower camelCase, and excludes proxy URL, Ollama, third-party provider, OpenAI official API key, proxy API key, and Electron/self-update fields. Persistent settings mutation remains a separate task.
|
||||
|
||||
## 2026-06-11 Original Project Gap Audit
|
||||
|
||||
@@ -2972,7 +2973,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav
|
||||
- [ ] **Admin account routes:** rebuild `/auth/status`, `/auth/accounts`, import/export, batch delete/status, health-check, per-account refresh, label, delete, reset usage, quota, Cookie CRUD, and quota warnings as `/admin/*` endpoints with admin envelopes and cursor pagination where a list is returned.
|
||||
- [ ] **Login routes:** implement OAuth PKCE login-start/code-relay/callback, device-login/device-poll, CLI auth import, manual access-token import, and logout as account-login flows. Keep this separate from admin password login and client API key creation.
|
||||
- [ ] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Remove provider/model binding semantics from the TypeScript version; Rust client API keys authorize only local `/v1/*` access.
|
||||
- [ ] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. Remove proxy URL, Ollama, third-party provider, OpenAI official key, and Electron/self-update settings.
|
||||
- [ ] **Settings:** implement admin settings for server/runtime fields that remain in scope: default model, reasoning effort, service tier, model aliases, refresh enabled/margin/concurrency, max concurrent per account, request interval, rotation strategy, tier priority, quota refresh/thresholds/skip exhausted, logs state/capacity/body capture, and usage history retention. Read-only `/admin/settings` is complete; persistent mutation and config-store writeback remain. Remove proxy URL, Ollama, third-party provider, OpenAI official key, and Electron/self-update settings.
|
||||
- [ ] **Diagnostics:** expand health/admin diagnostics to include authenticated state, pool summary, capacity summary, transport/fingerprint status, paths, runtime metadata, and test-connection checks. Keep production-local gating for sensitive debug endpoints.
|
||||
- [ ] **Logs and usage stats:** add `/admin/logs/state`, clear/detail, error-log equivalents if retained, and `/admin/usage-stats/summary` plus paginated or bounded history queries. Keep log file rotation and SQLite event pagination.
|
||||
- [ ] **Model catalog:** replace the hard-coded `/v1/models` list with a model store that supports static defaults, configured aliases, `-low/-medium/-high/-xhigh/-fast/-flex` suffix parsing, backend model fetching per plan, cache persistence, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/admin/refresh-models`, and `/debug/models`.
|
||||
|
||||
+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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
http::{Request, StatusCode},
|
||||
@@ -10,8 +12,9 @@ use codex_proxy_rs::{
|
||||
app::build_router,
|
||||
auth::admin_session::hash_admin_password,
|
||||
config::{
|
||||
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig,
|
||||
SecurityConfig, ServerConfig, TlsConfig,
|
||||
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
|
||||
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig,
|
||||
UsageStatsConfig,
|
||||
},
|
||||
state::AppState,
|
||||
storage::db::connect_sqlite,
|
||||
@@ -26,10 +29,31 @@ fn test_config(database_url: String) -> AppConfig {
|
||||
api: ApiConfig {
|
||||
base_url: "https://chatgpt.com/backend-api".to_string(),
|
||||
},
|
||||
model: ModelConfig {
|
||||
default_model: "gpt-5.5".to_string(),
|
||||
default_reasoning_effort: None,
|
||||
service_tier: None,
|
||||
aliases: BTreeMap::new(),
|
||||
},
|
||||
auth: AuthConfig {
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_enabled: true,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 3,
|
||||
request_interval_ms: 50,
|
||||
rotation_strategy: "least_used".to_string(),
|
||||
tier_priority: Vec::new(),
|
||||
},
|
||||
quota: QuotaConfig {
|
||||
refresh_interval_minutes: 5,
|
||||
warning_thresholds: QuotaWarningThresholds {
|
||||
primary: vec![80, 90],
|
||||
secondary: vec![80, 90],
|
||||
},
|
||||
skip_exhausted: true,
|
||||
},
|
||||
usage_stats: UsageStatsConfig {
|
||||
history_retention_days: None,
|
||||
},
|
||||
database: DatabaseConfig { url: database_url },
|
||||
security: SecurityConfig {
|
||||
@@ -46,6 +70,9 @@ fn test_config(database_url: String) -> AppConfig {
|
||||
directory: "logs".to_string(),
|
||||
max_file_bytes: 10_485_760,
|
||||
retention_days: 14,
|
||||
enabled: false,
|
||||
capacity: 2_000,
|
||||
capture_body: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
http::{Request, StatusCode},
|
||||
@@ -9,8 +11,9 @@ use tower::ServiceExt;
|
||||
use codex_proxy_rs::{
|
||||
app::build_router,
|
||||
config::{
|
||||
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig,
|
||||
SecurityConfig, ServerConfig, TlsConfig,
|
||||
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
|
||||
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig,
|
||||
UsageStatsConfig,
|
||||
},
|
||||
logs::{
|
||||
event::{EventLevel, EventLog},
|
||||
@@ -29,10 +32,31 @@ fn test_config(database_url: String) -> AppConfig {
|
||||
api: ApiConfig {
|
||||
base_url: "https://chatgpt.com/backend-api".to_string(),
|
||||
},
|
||||
model: ModelConfig {
|
||||
default_model: "gpt-5.5".to_string(),
|
||||
default_reasoning_effort: None,
|
||||
service_tier: None,
|
||||
aliases: BTreeMap::new(),
|
||||
},
|
||||
auth: AuthConfig {
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_enabled: true,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 3,
|
||||
request_interval_ms: 50,
|
||||
rotation_strategy: "least_used".to_string(),
|
||||
tier_priority: Vec::new(),
|
||||
},
|
||||
quota: QuotaConfig {
|
||||
refresh_interval_minutes: 5,
|
||||
warning_thresholds: QuotaWarningThresholds {
|
||||
primary: vec![80, 90],
|
||||
secondary: vec![80, 90],
|
||||
},
|
||||
skip_exhausted: true,
|
||||
},
|
||||
usage_stats: UsageStatsConfig {
|
||||
history_retention_days: None,
|
||||
},
|
||||
database: DatabaseConfig { url: database_url },
|
||||
security: SecurityConfig {
|
||||
@@ -49,6 +73,9 @@ fn test_config(database_url: String) -> AppConfig {
|
||||
directory: "logs".to_string(),
|
||||
max_file_bytes: 10_485_760,
|
||||
retention_days: 14,
|
||||
enabled: false,
|
||||
capacity: 2_000,
|
||||
capture_body: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use codex_proxy_rs::{
|
||||
app::build_router,
|
||||
config::{
|
||||
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
|
||||
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig,
|
||||
UsageStatsConfig,
|
||||
},
|
||||
state::AppState,
|
||||
storage::db::connect_sqlite,
|
||||
};
|
||||
|
||||
fn test_config(database_url: String) -> AppConfig {
|
||||
let mut aliases = BTreeMap::new();
|
||||
aliases.insert("codex-fast".to_string(), "gpt-5.5".to_string());
|
||||
AppConfig {
|
||||
server: ServerConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 0,
|
||||
},
|
||||
api: ApiConfig {
|
||||
base_url: "https://chatgpt.com/backend-api".to_string(),
|
||||
},
|
||||
model: ModelConfig {
|
||||
default_model: "gpt-5.5".to_string(),
|
||||
default_reasoning_effort: Some("high".to_string()),
|
||||
service_tier: Some("flex".to_string()),
|
||||
aliases,
|
||||
},
|
||||
auth: AuthConfig {
|
||||
refresh_margin_seconds: 240,
|
||||
refresh_enabled: true,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 4,
|
||||
request_interval_ms: 50,
|
||||
rotation_strategy: "least_used".to_string(),
|
||||
tier_priority: vec!["team".to_string(), "plus".to_string()],
|
||||
},
|
||||
quota: QuotaConfig {
|
||||
refresh_interval_minutes: 5,
|
||||
warning_thresholds: QuotaWarningThresholds {
|
||||
primary: vec![80, 90],
|
||||
secondary: vec![70, 95],
|
||||
},
|
||||
skip_exhausted: true,
|
||||
},
|
||||
usage_stats: UsageStatsConfig {
|
||||
history_retention_days: Some(30),
|
||||
},
|
||||
database: DatabaseConfig { url: database_url },
|
||||
security: SecurityConfig {
|
||||
master_key_file: "data/master.key".to_string(),
|
||||
api_key_pepper_file: "data/api-key-pepper.key".to_string(),
|
||||
},
|
||||
tls: TlsConfig {
|
||||
force_http11: false,
|
||||
},
|
||||
admin: AdminConfig {
|
||||
session_ttl_minutes: 1440,
|
||||
},
|
||||
logging: LoggingConfig {
|
||||
directory: "logs".to_string(),
|
||||
max_file_bytes: 10_485_760,
|
||||
retention_days: 14,
|
||||
enabled: true,
|
||||
capacity: 2_000,
|
||||
capture_body: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_settings_should_require_admin_session_cookie() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("admin-settings.sqlite");
|
||||
let url = format!("sqlite://{}", db.display());
|
||||
let pool = connect_sqlite(&url).await.unwrap();
|
||||
let app = build_router(AppState::with_pool(test_config(url), pool));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/admin/settings")
|
||||
.header("x-request-id", "req_settings")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["code"], 40101);
|
||||
assert_eq!(body["requestId"], "req_settings");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_settings_should_return_only_in_scope_runtime_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("admin-settings.sqlite");
|
||||
let url = format!("sqlite://{}", db.display());
|
||||
let pool = connect_sqlite(&url).await.unwrap();
|
||||
seed_admin_session(&pool, "session_1").await;
|
||||
let app = build_router(AppState::with_pool(test_config(url), pool));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri("/admin/settings")
|
||||
.header("cookie", "cpr_admin_session=session_1")
|
||||
.header("x-request-id", "req_settings")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response_json(response).await;
|
||||
assert_eq!(body["code"], 200);
|
||||
assert_eq!(body["requestId"], "req_settings");
|
||||
assert_eq!(
|
||||
body["data"],
|
||||
json!({
|
||||
"defaultModel": "gpt-5.5",
|
||||
"defaultReasoningEffort": "high",
|
||||
"serviceTier": "flex",
|
||||
"modelAliases": { "codex-fast": "gpt-5.5" },
|
||||
"refreshEnabled": true,
|
||||
"refreshMarginSeconds": 240,
|
||||
"refreshConcurrency": 2,
|
||||
"maxConcurrentPerAccount": 4,
|
||||
"requestIntervalMs": 50,
|
||||
"rotationStrategy": "least_used",
|
||||
"tierPriority": ["team", "plus"],
|
||||
"quotaRefreshIntervalMinutes": 5,
|
||||
"quotaWarningThresholds": {
|
||||
"primary": [80, 90],
|
||||
"secondary": [70, 95]
|
||||
},
|
||||
"quotaSkipExhausted": true,
|
||||
"logsEnabled": true,
|
||||
"logsCapacity": 2000,
|
||||
"logsCaptureBody": false,
|
||||
"usageHistoryRetentionDays": 30
|
||||
})
|
||||
);
|
||||
for removed_key in [
|
||||
"proxyUrl",
|
||||
"openaiApiKey",
|
||||
"ollama",
|
||||
"provider",
|
||||
"proxyApiKey",
|
||||
"autoUpdate",
|
||||
] {
|
||||
assert!(
|
||||
body["data"].get(removed_key).is_none(),
|
||||
"{removed_key} must stay out of the Rust settings API"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_admin_session(pool: &sqlx::SqlitePool, session_id: &str) {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"insert into admin_users (id, password_hash, created_at, updated_at) values (?, ?, ?, ?)",
|
||||
)
|
||||
.bind("admin_1")
|
||||
.bind("hash")
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query(
|
||||
"insert into admin_sessions (id, user_id, expires_at, created_at) values (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind("admin_1")
|
||||
.bind("2999-01-01T00:00:00Z")
|
||||
.bind(now)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn response_json(response: axum::response::Response) -> Value {
|
||||
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
@@ -9,10 +9,31 @@ server:
|
||||
port: 8080
|
||||
api:
|
||||
base_url: https://chatgpt.com/backend-api
|
||||
model:
|
||||
default: gpt-5.5
|
||||
default_reasoning_effort: null
|
||||
default_service_tier: null
|
||||
aliases: {}
|
||||
auth:
|
||||
refresh_margin_seconds: 300
|
||||
refresh_enabled: true
|
||||
refresh_concurrency: 2
|
||||
max_concurrent_per_account: 3
|
||||
request_interval_ms: 50
|
||||
rotation_strategy: least_used
|
||||
tier_priority: []
|
||||
quota:
|
||||
refresh_interval_minutes: 5
|
||||
warning_thresholds:
|
||||
primary:
|
||||
- 80
|
||||
- 90
|
||||
secondary:
|
||||
- 80
|
||||
- 90
|
||||
skip_exhausted: true
|
||||
usage_stats:
|
||||
history_retention_days: null
|
||||
database:
|
||||
url: sqlite://data/codex-proxy-rs.sqlite
|
||||
security:
|
||||
@@ -26,10 +47,15 @@ logging:
|
||||
directory: logs
|
||||
max_file_bytes: 10485760
|
||||
retention_days: 14
|
||||
enabled: false
|
||||
capacity: 2000
|
||||
capture_body: false
|
||||
"#;
|
||||
let cfg: AppConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cfg.api.base_url, "https://chatgpt.com/backend-api");
|
||||
assert_eq!(cfg.model.default_model, "gpt-5.5");
|
||||
assert_eq!(cfg.auth.refresh_margin_seconds, 300);
|
||||
assert_eq!(cfg.auth.rotation_strategy, "least_used");
|
||||
assert_eq!(cfg.database.url, "sqlite://data/codex-proxy-rs.sqlite");
|
||||
assert_eq!(cfg.security.master_key_file, "data/master.key");
|
||||
}
|
||||
@@ -45,10 +71,31 @@ server:
|
||||
port: 8080
|
||||
api:
|
||||
base_url: https://chatgpt.com/backend-api
|
||||
model:
|
||||
default: gpt-5.5
|
||||
default_reasoning_effort: null
|
||||
default_service_tier: null
|
||||
aliases: {}
|
||||
auth:
|
||||
refresh_margin_seconds: 300
|
||||
refresh_enabled: true
|
||||
refresh_concurrency: 2
|
||||
max_concurrent_per_account: 3
|
||||
request_interval_ms: 50
|
||||
rotation_strategy: least_used
|
||||
tier_priority: []
|
||||
quota:
|
||||
refresh_interval_minutes: 5
|
||||
warning_thresholds:
|
||||
primary:
|
||||
- 80
|
||||
- 90
|
||||
secondary:
|
||||
- 80
|
||||
- 90
|
||||
skip_exhausted: true
|
||||
usage_stats:
|
||||
history_retention_days: null
|
||||
database:
|
||||
url: sqlite://data/codex-proxy-rs.sqlite
|
||||
security:
|
||||
@@ -62,6 +109,9 @@ logging:
|
||||
directory: logs
|
||||
max_file_bytes: 10485760
|
||||
retention_days: 14
|
||||
enabled: false
|
||||
capacity: 2000
|
||||
capture_body: false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -82,6 +132,8 @@ logging:
|
||||
("CPRS_PORT", "19090"),
|
||||
("CPRS_DATABASE_URL", "sqlite://override.sqlite"),
|
||||
("CPRS_LOG_MAX_FILE_BYTES", "4096"),
|
||||
("CPRS_DEFAULT_MODEL", "gpt-5.5"),
|
||||
("CPRS_MAX_CONCURRENT_PER_ACCOUNT", "4"),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -89,6 +141,8 @@ logging:
|
||||
assert_eq!(cfg.server.host, "0.0.0.0");
|
||||
assert_eq!(cfg.server.port, 19090);
|
||||
assert_eq!(cfg.database.url, "sqlite://override.sqlite");
|
||||
assert_eq!(cfg.model.default_model, "gpt-5.5");
|
||||
assert_eq!(cfg.auth.max_concurrent_per_account, 4);
|
||||
assert_eq!(cfg.logging.directory, "local-logs");
|
||||
assert_eq!(cfg.logging.max_file_bytes, 4096);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
http::{Request, StatusCode},
|
||||
@@ -8,8 +10,9 @@ use tower::ServiceExt;
|
||||
use codex_proxy_rs::{
|
||||
app::build_router,
|
||||
config::{
|
||||
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig,
|
||||
SecurityConfig, ServerConfig, TlsConfig,
|
||||
AdminConfig, ApiConfig, AppConfig, AuthConfig, DatabaseConfig, LoggingConfig, ModelConfig,
|
||||
QuotaConfig, QuotaWarningThresholds, SecurityConfig, ServerConfig, TlsConfig,
|
||||
UsageStatsConfig,
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
@@ -23,10 +26,31 @@ fn test_config() -> AppConfig {
|
||||
api: ApiConfig {
|
||||
base_url: "https://chatgpt.com/backend-api".to_string(),
|
||||
},
|
||||
model: ModelConfig {
|
||||
default_model: "gpt-5.5".to_string(),
|
||||
default_reasoning_effort: None,
|
||||
service_tier: None,
|
||||
aliases: BTreeMap::new(),
|
||||
},
|
||||
auth: AuthConfig {
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_enabled: true,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 3,
|
||||
request_interval_ms: 50,
|
||||
rotation_strategy: "least_used".to_string(),
|
||||
tier_priority: Vec::new(),
|
||||
},
|
||||
quota: QuotaConfig {
|
||||
refresh_interval_minutes: 5,
|
||||
warning_thresholds: QuotaWarningThresholds {
|
||||
primary: vec![80, 90],
|
||||
secondary: vec![80, 90],
|
||||
},
|
||||
skip_exhausted: true,
|
||||
},
|
||||
usage_stats: UsageStatsConfig {
|
||||
history_retention_days: None,
|
||||
},
|
||||
database: DatabaseConfig {
|
||||
url: "sqlite://:memory:".to_string(),
|
||||
@@ -45,6 +69,9 @@ fn test_config() -> AppConfig {
|
||||
directory: "logs".to_string(),
|
||||
max_file_bytes: 10_485_760,
|
||||
retention_days: 14,
|
||||
enabled: false,
|
||||
capacity: 2_000,
|
||||
capture_body: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user