feat: add admin settings writeback

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-13 10:45:46 +08:00
co-authored by Codex
parent 7b8fcda9ab
commit 0119837770
9 changed files with 689 additions and 13 deletions
+44 -1
View File
@@ -96,7 +96,7 @@ 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.
Returns the in-scope runtime settings visible to the admin UI. This endpoint uses admin sessions, not client API keys.
Success response:
@@ -131,6 +131,49 @@ Success response:
}
```
### `PATCH /admin/settings`
Updates retained settings and writes them to root `local.yaml` as a config overlay. The route requires the admin session cookie. Client API keys and removed TypeScript-era fields are not accepted.
Request:
```json
{
"defaultModel": "gpt-6",
"defaultReasoningEffort": "medium",
"serviceTier": "priority",
"modelAliases": {
"fast": "gpt-6-fast"
},
"refreshEnabled": false,
"refreshMarginSeconds": 180,
"refreshConcurrency": 3,
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 125,
"rotationStrategy": "round_robin",
"tierPriority": ["pro", "plus"],
"quotaRefreshIntervalMinutes": 15,
"quotaWarningThresholds": {
"primary": [75, 90],
"secondary": [65, 95]
},
"quotaSkipExhausted": false,
"logsEnabled": false,
"logsCapacity": 3000,
"logsCaptureBody": true,
"usageHistoryRetentionDays": 60
}
```
Success response uses the same `data` shape as `GET /admin/settings`.
Validation:
- `rotationStrategy` must be `least_used`, `round_robin`, or `sticky`.
- Count/capacity/retention fields that represent concurrency, capacity, intervals, or retention must be greater than zero where zero would disable required service behavior.
- Quota warning thresholds must be between `0` and `100`.
- Removed settings such as `proxyUrl`, `openaiApiKey`, `ollama`, provider settings, proxy API key settings, and Electron/self-update settings return HTTP `400` with body code `40001`.
### `GET /admin/logs`
Returns cursor-paginated event logs for admin troubleshooting. The list uses the standard admin page envelope and lower camelCase fields.
File diff suppressed because one or more lines are too long
@@ -2952,7 +2952,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.
- [x] Add `/admin/settings` for the retained Rust settings surface. GET and PATCH are admin-session gated, lower camelCase, exclude proxy URL, Ollama, third-party provider, OpenAI official API key, proxy API key, and Electron/self-update fields, and PATCH writes retained config to root `local.yaml`.
- [x] Add a native static model catalog foundation. It exposes `gpt-5.5`, configured aliases, reasoning/service tier suffix parsing, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, and `/debug/models`. Backend model fetching/cache and `/admin/refresh-models` remain separate tasks.
- [x] Add encrypted admin account import/list foundations. `/admin/accounts/import` stores access/refresh tokens through `SecretBox` and `/admin/accounts` returns paginated sanitized account metadata without tokens or token decryption. The runtime now loads or creates the master key file before constructing repository-backed routes.
- [x] Add the first native Codex upstream lifecycle slice. Imported accounts are synchronized into the process account pool, `/v1/responses` acquires an account, sends Codex Desktop HTTP SSE requests with encrypted Cookie replay/capture, collects `response.completed`, records usage, and releases the slot. Startup pool snapshots, retry/fallback, 401 refresh, rate-limit capture, and WebSocket remain separate tasks.
@@ -2975,7 +2975,7 @@ The Rust project has no legacy compatibility requirement, but the in-scope behav
- [x] **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. Encrypted import, manual token add, CLI import, paginated sanitized list, native/Sub2API export, Sub2API import marking, label/status/delete, batch delete/status, Cookie CRUD, health/probe, refresh, quota, quota warnings, reset usage, admin auth status, and admin logout are complete.
- [x] **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. CLI auth import, validated manual token import, device login/poll, OAuth PKCE login-start/code-relay/callback, admin-session-gated auth status, and logout are complete. Keep this separate from admin password login and client API key creation.
- [x] **Client API key management:** implement admin CRUD for local client API keys: list/create/delete/batch-delete/label/status/export/import. Create/list/status/delete/batch-delete/label/export/import and `/v1/*` SQLite verification are complete. 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. 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.
- [x] **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. GET and PATCH `/admin/settings` are admin-session gated, PATCH validates retained fields, writes a root `local.yaml` overlay, and rejects proxy URL, Ollama, third-party provider, OpenAI official key, proxy API key, and Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task.
- [ ] **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 richer bounded history queries if needed. Keep log file rotation and SQLite event pagination. Initial `/admin/usage-stats` pagination and `/admin/usage-stats/summary` are complete.
- [x] **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`. Static defaults, aliases, suffix parsing, cached backend snapshots, plan allowlists, admin refresh, read-only model routes, and debug summary are complete.
@@ -2994,7 +2994,7 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/
- [x] **Account login flows:** Rust has admin login, native/Sub2API account import, Codex CLI auth import, validated manual token import, OpenAI OAuth device login/poll, OAuth PKCE login-start/code-relay/callback, admin-session-gated account logout, and `/auth/status`-equivalent `/admin/auth/status`. These are rebuilt as admin/session-gated account-login endpoints, not by treating local client API keys as passwords.
- [ ] **Admin account management:** Rust has paginated sanitized list, native/Sub2API import, native/Sub2API export, manual token add with upstream refresh-token exchange and JWT metadata/expires extraction, device-code token import, label/status/delete, batch delete/status, Cookie get/set/delete, per-account refresh/probe, reset usage, quota fetch/update, quota warnings, auth status, and logout. Missing persistence-health reporting and durable quota-window restore.
- [x] **Client API key management:** Rust creates/lists/status-toggles/deletes/batch-deletes/labels/exports/imports local `cpr_` keys and verifies them for `/v1/*`. Do not port the TypeScript provider/model binding or provider model-fetch APIs.
- [ ] **Settings:** Rust exposes read-only `/admin/settings` for retained fields. Missing admin-session-gated mutation/writeback with validation for default model, default reasoning effort, service tier, aliases, refresh settings, max concurrent accounts, request interval, rotation strategy, tier priority, quota refresh/threshold/skip settings, log state/capacity/body capture, and usage retention. Do not re-add proxy URL, provider, Ollama, proxy API key, or Electron/self-update settings.
- [x] **Settings:** Rust exposes admin-session-gated GET/PATCH `/admin/settings` for retained fields. PATCH validates default model, default reasoning effort, service tier, aliases, refresh settings, max concurrent accounts, request interval, rotation strategy, tier priority, quota refresh/threshold/skip settings, log state/capacity/body capture, and usage retention, writes root `local.yaml`, and does not re-add proxy URL, provider, Ollama, proxy API key, or Electron/self-update settings. Existing constructed runtime services still use construction-time config until restart or a later runtime reconfiguration task.
- [ ] **Logs and error diagnostics:** Rust has cursor-paginated SQLite event logs and rotating files. Missing `/admin/logs/state`, pause/enable mutation, clear, detail-by-id, direction/search filters, bounded in-memory request/response body capture, and the original local error-log grouping/raw/count/seen/report/clear APIs if retained for troubleshooting.
- [ ] **Usage and quota background jobs:** Rust records cumulative account usage from `/v1/*` and computes admin quota warnings from cached SQLite quota snapshots. Missing usage history snapshots (`raw`, `five_min`, `hourly`, `daily`), retention cleanup, passive quota-window recovery, active quota refresher for dirty/limit-reached accounts, and startup background scheduling.
- [ ] **Model catalog:** Rust has a static default catalog, aliases, suffix parsing, read routes, debug summary, backend Codex model fetch/probe, per-plan snapshots, model-plan index feeding account scheduling, `/admin/refresh-models`, and runtime cache persistence. Missing richer metadata fields and optional external model-cache loading if retained.
@@ -3014,7 +3014,7 @@ This pass re-read the TypeScript routes and core modules under `/home/zyy/Codes/
### Original Bugs or Risky Behavior to Correct During Rebuild
- [ ] Old `/admin/*` settings mutations used the proxy API key as a write gate. Rust must use admin sessions only.
- [x] Old `/admin/*` settings mutations used the proxy API key as a write gate. Rust settings mutation now uses admin sessions only and rejects client API key cross-use through the admin auth boundary.
- [ ] Old refresh fallback could be dangerous for one-time refresh tokens if retried after a mid-flight failure. Rust must keep the one-time RT preservation rule and retry only when the request definitely did not reach the server.
- [x] Old settings exposed proxy and provider knobs that are out of scope. Rust config and API keep proxy URL, provider, Ollama, OpenAI official key, proxy API key, and Electron/self-update fields out of the retained settings surface.
- [x] Old route responses mix multiple body shapes. Rust keeps `/v1/*` OpenAI-compatible and `/admin/*` lower camelCase `code/message/data/requestId`, backed by dedicated API contract tests.
+26 -2
View File
@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::{path::PathBuf, sync::Arc};
use sqlx::SqlitePool;
use tokio::sync::Mutex;
@@ -18,7 +18,7 @@ use crate::config::AppConfig;
use crate::logs::repository::EventLogRepository;
use crate::service::{
admin_auth::AdminAuthService, api_key::ApiKeyService, chat::ChatService, log::LogService,
responses::ResponsesService, usage::UsageService,
responses::ResponsesService, settings::SettingsService, usage::UsageService,
};
use crate::utils::crypto::SecretBox;
@@ -34,6 +34,7 @@ pub struct AppServices {
pub api_keys: ApiKeyService,
pub logs: LogService,
pub usage: UsageService,
pub settings: SettingsService,
pub chat: ChatService,
pub responses: ResponsesService,
pub models: ModelService,
@@ -46,6 +47,7 @@ struct AppStateDependencies {
api_key_hasher: Option<ApiKeyHasher>,
token_refresher: Option<Arc<dyn TokenRefresher>>,
oauth_client: Option<Arc<dyn OAuthClient>>,
local_config_path: Option<PathBuf>,
}
impl AppState {
@@ -63,6 +65,21 @@ impl AppState {
)
}
pub fn with_pool_and_local_config_path(
config: AppConfig,
pool: SqlitePool,
local_config_path: impl Into<PathBuf>,
) -> Self {
Self::from_dependencies(
config,
AppStateDependencies {
pool: Some(pool),
local_config_path: Some(local_config_path.into()),
..AppStateDependencies::default()
},
)
}
pub fn with_pool_and_secret_box(
config: AppConfig,
pool: SqlitePool,
@@ -139,6 +156,7 @@ impl AppState {
api_key_hasher: Some(api_key_hasher),
token_refresher: Some(token_refresher),
oauth_client: Some(oauth_client),
..AppStateDependencies::default()
},
)
}
@@ -161,6 +179,7 @@ impl AppState {
api_key_hasher,
token_refresher,
oauth_client,
local_config_path,
} = dependencies;
let pool_ref = pool.as_ref();
let secret_box_ref = secret_box.as_ref();
@@ -169,6 +188,10 @@ impl AppState {
let api_keys = api_key_service(pool_ref, api_key_hasher.as_ref());
let logs = log_service(pool_ref);
let usage = usage_service(pool_ref);
let settings = SettingsService::new(
config.clone(),
local_config_path.unwrap_or_else(|| PathBuf::from("local.yaml")),
);
let accounts = account_service(
&config,
account_repository(pool_ref, secret_box_ref),
@@ -205,6 +228,7 @@ impl AppState {
api_keys,
logs,
usage,
settings,
chat,
responses,
models,
+2 -2
View File
@@ -23,7 +23,7 @@ use super::{
},
logs::logs,
models::refresh_models,
settings::settings,
settings::{settings, update_settings},
usage::{usage_stats, usage_stats_summary},
};
@@ -43,7 +43,7 @@ pub fn router() -> Router<AppState> {
get(auth_device_poll),
)
.route("/admin/logs", get(logs))
.route("/admin/settings", get(settings))
.route("/admin/settings", get(settings).patch(update_settings))
.route("/admin/refresh-models", post(refresh_models))
.route("/admin/usage-stats", get(usage_stats))
.route("/admin/usage-stats/summary", get(usage_stats_summary))
+157 -2
View File
@@ -1,19 +1,43 @@
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension,
};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
app::state::AppState,
config::{AppConfig, QuotaWarningThresholds},
http::middleware::RequestId,
service::settings::{SettingsServiceError, SettingsUpdate},
};
use super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
const ALLOWED_SETTINGS_KEYS: [&str; 18] = [
"defaultModel",
"defaultReasoningEffort",
"serviceTier",
"modelAliases",
"refreshEnabled",
"refreshMarginSeconds",
"refreshConcurrency",
"maxConcurrentPerAccount",
"requestIntervalMs",
"rotationStrategy",
"tierPriority",
"quotaRefreshIntervalMinutes",
"quotaWarningThresholds",
"quotaSkipExhausted",
"logsEnabled",
"logsCapacity",
"logsCaptureBody",
"usageHistoryRetentionDays",
];
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminSettingsData {
@@ -62,6 +86,54 @@ impl AdminSettingsData {
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AdminSettingsPatch {
default_model: Option<String>,
default_reasoning_effort: Option<String>,
service_tier: Option<String>,
model_aliases: Option<std::collections::BTreeMap<String, String>>,
refresh_enabled: Option<bool>,
refresh_margin_seconds: Option<u64>,
refresh_concurrency: Option<u32>,
max_concurrent_per_account: Option<usize>,
request_interval_ms: Option<u64>,
rotation_strategy: Option<String>,
tier_priority: Option<Vec<String>>,
quota_refresh_interval_minutes: Option<u64>,
quota_warning_thresholds: Option<QuotaWarningThresholds>,
quota_skip_exhausted: Option<bool>,
logs_enabled: Option<bool>,
logs_capacity: Option<u32>,
logs_capture_body: Option<bool>,
usage_history_retention_days: Option<u64>,
}
impl From<AdminSettingsPatch> for SettingsUpdate {
fn from(patch: AdminSettingsPatch) -> Self {
Self {
default_model: patch.default_model,
default_reasoning_effort: patch.default_reasoning_effort,
service_tier: patch.service_tier,
model_aliases: patch.model_aliases,
refresh_enabled: patch.refresh_enabled,
refresh_margin_seconds: patch.refresh_margin_seconds,
refresh_concurrency: patch.refresh_concurrency,
max_concurrent_per_account: patch.max_concurrent_per_account,
request_interval_ms: patch.request_interval_ms,
rotation_strategy: patch.rotation_strategy,
tier_priority: patch.tier_priority,
quota_refresh_interval_minutes: patch.quota_refresh_interval_minutes,
quota_warning_thresholds: patch.quota_warning_thresholds,
quota_skip_exhausted: patch.quota_skip_exhausted,
logs_enabled: patch.logs_enabled,
logs_capacity: patch.logs_capacity,
logs_capture_body: patch.logs_capture_body,
usage_history_retention_days: patch.usage_history_retention_days,
}
}
}
pub async fn settings(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
@@ -69,9 +141,92 @@ pub async fn settings(
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let config = state.services.settings.current().await;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminSettingsData::from_config(state.config()), request_id),
AdminEnvelope::ok(AdminSettingsData::from_config(&config), request_id),
))
}
pub async fn update_settings(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
body: Bytes,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
require_admin_session(&state, &headers, &request_id).await?;
let patch = parse_settings_patch(&body, &request_id)?;
let config = state
.services
.settings
.update(patch.into())
.await
.map_err(|err| settings_service_error(err, &request_id))?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(AdminSettingsData::from_config(&config), request_id),
))
}
fn parse_settings_patch(body: &[u8], request_id: &str) -> Result<AdminSettingsPatch, AdminError> {
let value = serde_json::from_slice::<Value>(body).map_err(|_| {
AdminError::new(
StatusCode::BAD_REQUEST,
40000,
"Malformed JSON body",
request_id,
)
})?;
let object = value.as_object().ok_or_else(|| {
AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Settings payload must be an object",
request_id,
)
})?;
if let Some(key) = object
.keys()
.find(|key| !ALLOWED_SETTINGS_KEYS.contains(&key.as_str()))
{
return Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
format!("Unsupported settings field: {key}"),
request_id,
));
}
serde_json::from_value(value).map_err(|_| {
AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Invalid settings payload",
request_id,
)
})
}
fn settings_service_error(error: SettingsServiceError, request_id: &str) -> AdminError {
match error {
SettingsServiceError::InvalidField { field, message } => AdminError::new(
StatusCode::BAD_REQUEST,
40001,
format!("Invalid setting {field}: {message}"),
request_id,
),
SettingsServiceError::Serialize(_)
| SettingsServiceError::CreateDirectory(_)
| SettingsServiceError::Write(_) => AdminError::new(
StatusCode::INTERNAL_SERVER_ERROR,
50000,
"Failed to persist settings",
request_id,
),
}
}
+1
View File
@@ -3,4 +3,5 @@ pub mod api_key;
pub mod chat;
pub mod log;
pub mod responses;
pub mod settings;
pub mod usage;
+278
View File
@@ -0,0 +1,278 @@
use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
use thiserror::Error;
use tokio::sync::RwLock;
use crate::config::{
AppConfig, AuthConfig, LoggingConfig, ModelConfig, QuotaConfig, QuotaWarningThresholds,
UsageStatsConfig,
};
const ROTATION_STRATEGIES: [&str; 3] = ["least_used", "round_robin", "sticky"];
#[derive(Clone)]
pub struct SettingsService {
current: Arc<RwLock<AppConfig>>,
local_config_path: Arc<PathBuf>,
}
#[derive(Debug, Default)]
pub struct SettingsUpdate {
pub default_model: Option<String>,
pub default_reasoning_effort: Option<String>,
pub service_tier: Option<String>,
pub model_aliases: Option<BTreeMap<String, String>>,
pub refresh_enabled: Option<bool>,
pub refresh_margin_seconds: Option<u64>,
pub refresh_concurrency: Option<u32>,
pub max_concurrent_per_account: Option<usize>,
pub request_interval_ms: Option<u64>,
pub rotation_strategy: Option<String>,
pub tier_priority: Option<Vec<String>>,
pub quota_refresh_interval_minutes: Option<u64>,
pub quota_warning_thresholds: Option<QuotaWarningThresholds>,
pub quota_skip_exhausted: Option<bool>,
pub logs_enabled: Option<bool>,
pub logs_capacity: Option<u32>,
pub logs_capture_body: Option<bool>,
pub usage_history_retention_days: Option<u64>,
}
#[derive(Debug, Error)]
pub enum SettingsServiceError {
#[error("invalid setting `{field}`: {message}")]
InvalidField { field: String, message: String },
#[error("serialize settings overlay")]
Serialize(#[source] serde_yaml::Error),
#[error("create settings directory")]
CreateDirectory(#[source] std::io::Error),
#[error("write settings overlay")]
Write(#[source] std::io::Error),
}
#[derive(Debug, serde::Serialize)]
struct SettingsConfigOverlay {
model: ModelConfig,
auth: AuthConfig,
quota: QuotaConfig,
usage_stats: UsageStatsConfig,
logging: LoggingConfig,
}
impl SettingsService {
pub fn new(config: AppConfig, local_config_path: impl Into<PathBuf>) -> Self {
Self {
current: Arc::new(RwLock::new(config)),
local_config_path: Arc::new(local_config_path.into()),
}
}
pub async fn current(&self) -> AppConfig {
self.current.read().await.clone()
}
pub async fn update(&self, update: SettingsUpdate) -> Result<AppConfig, SettingsServiceError> {
let mut current = self.current.write().await;
let mut next = current.clone();
apply_update(&mut next, update)?;
self.write_overlay(&next).await?;
*current = next.clone();
Ok(next)
}
async fn write_overlay(&self, config: &AppConfig) -> Result<(), SettingsServiceError> {
if let Some(parent) = self
.local_config_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
{
tokio::fs::create_dir_all(parent)
.await
.map_err(SettingsServiceError::CreateDirectory)?;
}
let overlay = SettingsConfigOverlay::from(config);
let content = serde_yaml::to_string(&overlay).map_err(SettingsServiceError::Serialize)?;
tokio::fs::write(self.local_config_path.as_ref(), content)
.await
.map_err(SettingsServiceError::Write)
}
}
impl From<&AppConfig> for SettingsConfigOverlay {
fn from(config: &AppConfig) -> Self {
Self {
model: config.model.clone(),
auth: config.auth.clone(),
quota: config.quota.clone(),
usage_stats: config.usage_stats.clone(),
logging: config.logging.clone(),
}
}
}
fn apply_update(
config: &mut AppConfig,
update: SettingsUpdate,
) -> Result<(), SettingsServiceError> {
if let Some(default_model) = update.default_model {
config.model.default_model = non_empty_string("defaultModel", default_model)?;
}
if let Some(default_reasoning_effort) = update.default_reasoning_effort {
config.model.default_reasoning_effort = Some(non_empty_string(
"defaultReasoningEffort",
default_reasoning_effort,
)?);
}
if let Some(service_tier) = update.service_tier {
config.model.service_tier = Some(non_empty_string("serviceTier", service_tier)?);
}
if let Some(model_aliases) = update.model_aliases {
config.model.aliases = validate_aliases(model_aliases)?;
}
if let Some(refresh_enabled) = update.refresh_enabled {
config.auth.refresh_enabled = refresh_enabled;
}
if let Some(refresh_margin_seconds) = update.refresh_margin_seconds {
config.auth.refresh_margin_seconds = refresh_margin_seconds;
}
if let Some(refresh_concurrency) = update.refresh_concurrency {
config.auth.refresh_concurrency = positive_u32("refreshConcurrency", refresh_concurrency)?;
}
if let Some(max_concurrent_per_account) = update.max_concurrent_per_account {
config.auth.max_concurrent_per_account =
positive_usize("maxConcurrentPerAccount", max_concurrent_per_account)?;
}
if let Some(request_interval_ms) = update.request_interval_ms {
config.auth.request_interval_ms = request_interval_ms;
}
if let Some(rotation_strategy) = update.rotation_strategy {
config.auth.rotation_strategy = validate_rotation_strategy(rotation_strategy)?;
}
if let Some(tier_priority) = update.tier_priority {
config.auth.tier_priority = validate_tier_priority(tier_priority)?;
}
if let Some(quota_refresh_interval_minutes) = update.quota_refresh_interval_minutes {
config.quota.refresh_interval_minutes = positive_u64(
"quotaRefreshIntervalMinutes",
quota_refresh_interval_minutes,
)?;
}
if let Some(quota_warning_thresholds) = update.quota_warning_thresholds {
validate_thresholds(&quota_warning_thresholds)?;
config.quota.warning_thresholds = quota_warning_thresholds;
}
if let Some(quota_skip_exhausted) = update.quota_skip_exhausted {
config.quota.skip_exhausted = quota_skip_exhausted;
}
if let Some(logs_enabled) = update.logs_enabled {
config.logging.enabled = logs_enabled;
}
if let Some(logs_capacity) = update.logs_capacity {
config.logging.capacity = positive_u32("logsCapacity", logs_capacity)?;
}
if let Some(logs_capture_body) = update.logs_capture_body {
config.logging.capture_body = logs_capture_body;
}
if let Some(usage_history_retention_days) = update.usage_history_retention_days {
config.usage_stats.history_retention_days = Some(positive_u64(
"usageHistoryRetentionDays",
usage_history_retention_days,
)?);
}
Ok(())
}
fn validate_aliases(
aliases: BTreeMap<String, String>,
) -> Result<BTreeMap<String, String>, SettingsServiceError> {
let mut validated = BTreeMap::new();
for (alias, target) in aliases {
let alias = non_empty_string("modelAliases", alias)?;
let target = non_empty_string("modelAliases", target)?;
validated.insert(alias, target);
}
Ok(validated)
}
fn validate_tier_priority(tiers: Vec<String>) -> Result<Vec<String>, SettingsServiceError> {
let mut validated = Vec::with_capacity(tiers.len());
for tier in tiers {
validated.push(non_empty_string("tierPriority", tier)?);
}
Ok(validated)
}
fn validate_rotation_strategy(strategy: String) -> Result<String, SettingsServiceError> {
let strategy = non_empty_string("rotationStrategy", strategy)?;
if ROTATION_STRATEGIES.contains(&strategy.as_str()) {
Ok(strategy)
} else {
Err(invalid_field(
"rotationStrategy",
"must be one of least_used, round_robin, sticky",
))
}
}
fn validate_thresholds(thresholds: &QuotaWarningThresholds) -> Result<(), SettingsServiceError> {
if thresholds.primary.is_empty() && thresholds.secondary.is_empty() {
return Err(invalid_field(
"quotaWarningThresholds",
"must include at least one threshold",
));
}
if thresholds
.primary
.iter()
.chain(thresholds.secondary.iter())
.any(|threshold| *threshold > 100)
{
return Err(invalid_field(
"quotaWarningThresholds",
"thresholds must be between 0 and 100",
));
}
Ok(())
}
fn non_empty_string(field: &str, value: String) -> Result<String, SettingsServiceError> {
let value = value.trim().to_string();
if value.is_empty() {
Err(invalid_field(field, "must not be empty"))
} else {
Ok(value)
}
}
fn positive_u32(field: &str, value: u32) -> Result<u32, SettingsServiceError> {
if value == 0 {
Err(invalid_field(field, "must be greater than 0"))
} else {
Ok(value)
}
}
fn positive_u64(field: &str, value: u64) -> Result<u64, SettingsServiceError> {
if value == 0 {
Err(invalid_field(field, "must be greater than 0"))
} else {
Ok(value)
}
}
fn positive_usize(field: &str, value: usize) -> Result<usize, SettingsServiceError> {
if value == 0 {
Err(invalid_field(field, "must be greater than 0"))
} else {
Ok(value)
}
}
fn invalid_field(field: &str, message: impl Into<String>) -> SettingsServiceError {
SettingsServiceError::InvalidField {
field: field.to_string(),
message: message.into(),
}
}
+175 -1
View File
@@ -1,4 +1,4 @@
use std::collections::BTreeMap;
use std::{collections::BTreeMap, fs, path::Path};
use axum::{
body::Body,
@@ -81,6 +81,14 @@ fn test_config(database_url: String) -> AppConfig {
}
}
fn write_config_yaml(path: &Path, config: &AppConfig) {
fs::write(
path.join("config.yaml"),
serde_yaml::to_string(config).unwrap(),
)
.unwrap();
}
#[tokio::test]
async fn admin_settings_should_require_admin_session_cookie() {
let dir = tempfile::tempdir().unwrap();
@@ -107,6 +115,172 @@ async fn admin_settings_should_require_admin_session_cookie() {
assert_eq!(body["requestId"], "req_settings");
}
#[tokio::test]
async fn admin_settings_patch_should_require_admin_session_cookie() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-settings-patch-auth.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("PATCH")
.uri("/admin/settings")
.header("content-type", "application/json")
.header("x-request-id", "req_settings_patch_auth")
.body(Body::from(r#"{"defaultModel":"gpt-5.5"}"#))
.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_patch_auth");
}
#[tokio::test]
async fn admin_settings_patch_should_persist_retained_fields_to_local_yaml() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-settings-patch.sqlite");
let url = format!("sqlite://{}", db.display());
let config = test_config(url);
write_config_yaml(dir.path(), &config);
let pool = connect_sqlite(&config.database.url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let app = build_router(AppState::with_pool_and_local_config_path(
config,
pool,
dir.path().join("local.yaml"),
));
let response = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri("/admin/settings")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_settings_patch")
.body(Body::from(
json!({
"defaultModel": "gpt-6",
"defaultReasoningEffort": "medium",
"serviceTier": "priority",
"modelAliases": { "fast": "gpt-6-fast" },
"refreshEnabled": false,
"refreshMarginSeconds": 180,
"refreshConcurrency": 3,
"maxConcurrentPerAccount": 5,
"requestIntervalMs": 125,
"rotationStrategy": "round_robin",
"tierPriority": ["pro", "plus"],
"quotaRefreshIntervalMinutes": 15,
"quotaWarningThresholds": {
"primary": [75, 90],
"secondary": [65, 95]
},
"quotaSkipExhausted": false,
"logsEnabled": false,
"logsCapacity": 3000,
"logsCaptureBody": true,
"usageHistoryRetentionDays": 60
})
.to_string(),
))
.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_patch");
assert_eq!(body["data"]["defaultModel"], "gpt-6");
assert_eq!(body["data"]["rotationStrategy"], "round_robin");
assert_eq!(
body["data"]["quotaWarningThresholds"]["primary"],
json!([75, 90])
);
let local_yaml = fs::read_to_string(dir.path().join("local.yaml")).unwrap();
assert!(!local_yaml.contains("proxyUrl"));
assert!(!local_yaml.contains("openaiApiKey"));
assert!(!local_yaml.contains("ollama"));
let reloaded = AppConfig::load_from_dir(dir.path()).unwrap();
assert_eq!(reloaded.model.default_model, "gpt-6");
assert_eq!(
reloaded.model.aliases.get("fast").map(String::as_str),
Some("gpt-6-fast")
);
assert!(!reloaded.auth.refresh_enabled);
assert_eq!(reloaded.auth.rotation_strategy, "round_robin");
assert_eq!(reloaded.quota.warning_thresholds.primary, vec![75, 90]);
assert_eq!(reloaded.usage_stats.history_retention_days, Some(60));
let get_response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/settings")
.header("cookie", "cpr_admin_session=session_1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(get_response.status(), StatusCode::OK);
let body = response_json(get_response).await;
assert_eq!(body["data"]["defaultModel"], "gpt-6");
}
#[tokio::test]
async fn admin_settings_patch_should_reject_removed_or_invalid_fields() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("admin-settings-patch-invalid.sqlite");
let url = format!("sqlite://{}", db.display());
let config = test_config(url);
write_config_yaml(dir.path(), &config);
let pool = connect_sqlite(&config.database.url).await.unwrap();
seed_admin_session(&pool, "session_1").await;
let app = build_router(AppState::with_pool_and_local_config_path(
config,
pool,
dir.path().join("local.yaml"),
));
let response = app
.oneshot(
Request::builder()
.method("PATCH")
.uri("/admin/settings")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_settings_patch_invalid")
.body(Body::from(
json!({
"proxyUrl": "http://127.0.0.1:8080",
"rotationStrategy": "random"
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response_json(response).await;
assert_eq!(body["code"], 40001);
assert_eq!(body["requestId"], "req_settings_patch_invalid");
assert!(!dir.path().join("local.yaml").exists());
}
#[tokio::test]
async fn admin_settings_should_return_only_in_scope_runtime_fields() {
let dir = tempfile::tempdir().unwrap();