From cfec8bac00a8adcd15a1db18779404be4d2c9ea8 Mon Sep 17 00:00:00 2001 From: zyycn Date: Fri, 12 Jun 2026 00:52:52 +0800 Subject: [PATCH] feat: add read-only admin settings Co-authored-by: Codex --- config/default.yaml | 24 +++ docs/api.md | 37 ++++ docs/implementation-status.md | 4 +- .../plans/2026-06-11-codex-proxy-rs.md | 3 +- src/app.rs | 3 +- src/config.rs | 80 ++++++- src/http/admin.rs | 111 ++++++++-- tests/admin_login_route_test.rs | 31 ++- tests/admin_logs_route_test.rs | 31 ++- tests/admin_settings_route_test.rs | 201 ++++++++++++++++++ tests/config_test.rs | 54 +++++ tests/routes_responses_test.rs | 31 ++- 12 files changed, 583 insertions(+), 27 deletions(-) create mode 100644 tests/admin_settings_route_test.rs diff --git a/config/default.yaml b/config/default.yaml index 786d92a49..4b3e0a856 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -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 diff --git a/docs/api.md b/docs/api.md index 3da4112c0..646c059cd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 diff --git a/docs/implementation-status.md b/docs/implementation-status.md index c4917b300..f2710aac9 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -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` 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` 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::` 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. | diff --git a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md index dd17cead9..b7cd9853c 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -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`. diff --git a/src/app.rs b/src/app.rs index 8b6884d09..ec5607a03 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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)) diff --git a/src/config.rs b/src/config.rs index c63862f74..7c90b3977 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 = Result; 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, + #[serde(rename = "default_service_tier")] + pub service_tier: Option, + pub aliases: BTreeMap, +} + #[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, +} + +#[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, + pub secondary: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct UsageStatsConfig { + pub history_retention_days: Option, } #[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(key: &str, value: &str) -> ConfigResult @@ -136,3 +198,19 @@ where .parse::() .map_err(|error| config::ConfigError::Message(format!("invalid {key}: {error}"))) } + +fn parse_optional_env(key: &str, value: &str) -> ConfigResult> +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 { + let value = value.trim(); + (!value.is_empty() && value != "null").then(|| value.to_string()) +} diff --git a/src/http/admin.rs b/src/http/admin.rs index 2bbdac8c5..229d846bb 100644 --- a/src/http/admin.rs +++ b/src/http/admin.rs @@ -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, + pub service_tier: Option, + pub model_aliases: std::collections::BTreeMap, + 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, + 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, +} + +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, Extension(request_id): Extension, @@ -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, + Extension(request_id): Extension, + 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()), + } +} diff --git a/tests/admin_login_route_test.rs b/tests/admin_login_route_test.rs index 0c69c4a78..5fc8e6339 100644 --- a/tests/admin_login_route_test.rs +++ b/tests/admin_login_route_test.rs @@ -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, }, } } diff --git a/tests/admin_logs_route_test.rs b/tests/admin_logs_route_test.rs index 93f52b4f5..8f582f36b 100644 --- a/tests/admin_logs_route_test.rs +++ b/tests/admin_logs_route_test.rs @@ -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, }, } } diff --git a/tests/admin_settings_route_test.rs b/tests/admin_settings_route_test.rs new file mode 100644 index 000000000..3126518ac --- /dev/null +++ b/tests/admin_settings_route_test.rs @@ -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() +} diff --git a/tests/config_test.rs b/tests/config_test.rs index a65c84cc2..5716ce8bd 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -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); } diff --git a/tests/routes_responses_test.rs b/tests/routes_responses_test.rs index f56a647f3..c73e59f48 100644 --- a/tests/routes_responses_test.rs +++ b/tests/routes_responses_test.rs @@ -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, }, } }