feat: add read-only admin settings

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-12 00:52:52 +08:00
co-authored by Codex
parent 73cf235ee8
commit cfec8bac00
12 changed files with 583 additions and 27 deletions
+24
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()),
}
}
+29 -2
View File
@@ -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,
},
}
}
+29 -2
View File
@@ -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,
},
}
}
+201
View File
@@ -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()
}
+54
View File
@@ -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);
}
+29 -2
View File
@@ -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,
},
}
}