mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 16:06:08 +00:00
feat: add admin settings writeback
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
+44
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ pub mod api_key;
|
||||
pub mod chat;
|
||||
pub mod log;
|
||||
pub mod responses;
|
||||
pub mod settings;
|
||||
pub mod usage;
|
||||
|
||||
@@ -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("a_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(),
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user