From 011983777015bc03f939ee52146bdb5d16ea1b33 Mon Sep 17 00:00:00 2001 From: zyycn Date: Sat, 13 Jun 2026 10:45:46 +0800 Subject: [PATCH] feat: add admin settings writeback Co-authored-by: Codex --- docs/api.md | 45 ++- docs/implementation-status.md | 3 +- .../plans/2026-06-11-codex-proxy-rs.md | 8 +- src/app/state.rs | 28 +- src/http/admin/router.rs | 4 +- src/http/admin/settings.rs | 159 +++++++++- src/service/mod.rs | 1 + src/service/settings.rs | 278 ++++++++++++++++++ tests/admin_settings_route_test.rs | 176 ++++++++++- 9 files changed, 689 insertions(+), 13 deletions(-) create mode 100644 src/service/settings.rs diff --git a/docs/api.md b/docs/api.md index ddc7871b0..c3118d087 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 233397cb5..01186d315 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -7,6 +7,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` + `cfec8ba` | `cargo test default_config_keeps_only_codex_backend`; `cargo test config_loader_merges_config_and_local_yaml`; full `fmt/test/clippy` before commit | Defines Codex-only config, root `config.yaml` plus optional local YAML loading, `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. | +| Admin settings | Completed | `pending` | `cargo test --test admin_settings_route_test admin_settings_patch`; `cargo test --test admin_settings_route_test` | Adds admin-session-gated `PATCH /admin/settings` for retained model/auth/quota/logging/usage settings. Mutations validate retained fields, reject removed proxy/Ollama/provider/OpenAI-key/Electron settings with admin error envelopes, write a root `local.yaml` overlay, and update the same-process settings view. Already constructed runtime services still use their construction-time config until a later runtime reconfiguration task or service restart. | | 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 | Partial | `24d5799` + `4e0d1e5` + `e9fdea0` + `8af350d` + `960bbb2` | `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`; `cargo test --test admin_api_keys_route_test`; `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `960bbb2`; current local key import/export targeted `cargo test --test admin_api_keys_route_test` | 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. `/admin/api-keys` can create/list local client keys, status-enable/status-disable local client keys, label local client keys, delete local client keys, batch-delete local client keys, export local client key metadata, import metadata by rotating to new local `cpr_` keys, and `/v1/*` verifies only enabled keys against SQLite instead of accepting any `cpr_` string. These routes are Rust-local client auth utilities, not TypeScript third-party provider API-key parity. Provider API key pools and provider key import/export remain out of scope; local `cpr_` key plaintext and key hashes are not exportable after HMAC storage, and import returns newly generated plaintext values only once. | | Secret encryption | Completed | `105aa58` + `228febd` | `cargo test secret_box_encrypts_and_decrypts_without_storing_plaintext`; `cargo test secret_box_load_or_create_should_reuse_existing_master_key_file`; full `fmt/test/clippy` before commit | AES-256-GCM `SecretBox` stores upstream secrets as `v1::` without plaintext and can load/create the runtime master key file used by repositories. | @@ -16,7 +17,7 @@ Update this file before each feature commit. | Account pool and refresh | Partial | `0f97c5c` + `2f25db8` + `4dc0c9a` + `c2cf7a6` + `636fa79` + `fdb9374` + `3ecd5b6` + `3960a35` + `db6cb98` + `93cfee3` + `6d18d2c` + `a32a53c` + `1d6c301` + `a3b4d2b` | `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`; `cargo test --test oauth_refresh_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test account_pool_startup_test`; `cargo test --test codex_websocket_test`; Task 5 targeted `cargo test --test v1_upstream_route_test`, `cargo test --test chat_completions_route_test`, `cargo test --test account_pool_scheduling_test`, and WebSocket fallback route tests; Task 7 targeted `cargo test --test admin_accounts_route_test`; auth status/logout targeted `cargo test --test admin_accounts_route_test`; device login targeted `cargo test --test admin_accounts_route_test admin_auth_device`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; PKCE auth-flow targeted `cargo test --test admin_accounts_route_test admin_auth_`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; full `fmt/test/clippy` before `db6cb98` | Implements base account status filtering, refresh scheduler semantics, real OpenAI OAuth refresh-token exchange, OpenAI OAuth device-code polling into the shared validated account import path, OpenAI OAuth PKCE authorization-code exchange into the same path, 401-triggered refresh and single retry, 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, capacity summary, settings-backed pool construction, same-process manual/import/CLI/device/PKCE import-to-pool sync, startup pool snapshots from SQLite with usage-based last-used metadata, in-memory 429 quota cooldown marking, Cloudflare cooldown mutation, pool status mutation for upstream fallback, admin label/status/delete/runtime refresh synchronization, admin reset-usage last-used clearing, runtime pool clearing on logout, shared fallback acquisition for non-streaming Responses, HTTP SSE Responses setup, Chat Completions, and non-history WebSocket handshakes before request body send. `previous_response_id` WebSocket requests remain account-affine on classified failures. Still missing durable release-time slot state and durable quota cooldown persistence because the current account schema has no dedicated cooldown column. | | Account repository | Completed | `14fe706` + `db6cb98` + `72438c6` + `869b6f8` + `93cfee3` + `a32a53c` + `b879dd6` | `cargo test --test account_repository_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test admin_accounts_cookies_quota_test`; `cargo fmt --check`; `cargo clippy --all-targets --all-features --locked -- -D warnings`; `cargo test`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `72438c6`; auth status/logout targeted `cargo test --test admin_accounts_route_test`; quota warnings targeted and full gate before `b879dd6` | Adds encrypted SQLite account token storage, decrypting reads, metadata-only reads for auth status, cursor pagination, full export reads, status/label updates, account deletion, account-wide delete for logout, release-time usage counter persistence, refresh-token-preserving token updates, ChatGPT account/user identity lookup, JWT-claim metadata/token updates that preserve existing labels, quota JSON caching, quota warning snapshot reads, and per-account local usage counter reset. | | Translation | Completed | `eff182e` + `b9c30ba` | `cargo test --test routes_chat_test`; `cargo test --test chat_completions_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` | Adds OpenAI Chat request types, expanded Codex Responses request type, OpenAI Chat to Codex translation for system/developer instructions, multimodal content, tool/function calls, response formats, reasoning effort, service tier, and parallel tool calls. Codex SSE now converts back to OpenAI chat completions and chat chunks with text deltas, reasoning content, tool calls, usage details, and OpenAI-compatible error bodies. | -| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `1738f24` + `0b6cc7e` + `f75db60` + `4b19846` + `db6cb98` + `16eb4ed` + `8af350d` + `203270f` + `960bbb2` + `72438c6` + `16938e6` + `869b6f8` + `e1f35e0` + `93cfee3` + `bfe561d` + `cf5ff0b` + `6d18d2c` + `a32a53c` + `1d6c301` + `a3b4d2b` | `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`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test cli_auth_import_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test admin_accounts_cookies_quota_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; targeted HTTP SSE error tests before `bfe561d`; targeted local API key import/export tests before `cf5ff0b`; WebSocket fallback targeted route tests; auth status/logout targeted `cargo test --test admin_accounts_route_test`; device login targeted `cargo test --test admin_accounts_route_test admin_auth_device`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; PKCE auth-flow targeted `cargo test --test admin_accounts_route_test admin_auth_`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` plus real-file smoke before `0b6cc7e`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `16eb4ed`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `203270f`; full `fmt/test/clippy` before `960bbb2`; full `fmt/test/clippy` before `72438c6`; full `fmt/test/clippy` before `16938e6` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/auth/status`, `/admin/auth/logout`, `/admin/auth/login-start`, `/admin/auth/code-relay`, `/admin/auth/callback`, `/auth/callback`, `/admin/auth/device-login`, `/admin/auth/device-poll/{device_code}`, `/admin/accounts`, `/admin/accounts/quota-warnings`, `/admin/accounts/export`, `/admin/accounts/batch-delete`, `/admin/accounts/batch-status`, `/admin/accounts/health-check`, `/admin/accounts/{id}`, `/admin/accounts/{id}/label`, `/admin/accounts/{id}/status`, `/admin/accounts/{id}/refresh`, `/admin/accounts/{id}/reset-usage`, `/admin/accounts/{id}/quota`, `/admin/accounts/{id}/cookies`, `/admin/accounts/import`, `/admin/accounts/import-cli`, `/admin/api-keys`, `/admin/api-keys/export`, `/admin/api-keys/import`, `/admin/api-keys/batch-delete`, `/admin/api-keys/{id}`, `/admin/api-keys/{id}/label`, `/admin/api-keys/{id}/status`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/admin/refresh-models`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses for `previous_response_id`, explicit non-history WebSocket Responses via `use_websocket`/`useWebSocket`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, WebSocket setup fallback after classified handshake failures when no `previous_response_id` is present, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` is read-only with admin-session auth and lower camelCase envelope. `/admin/auth/status` returns sanitized authenticated state, first account metadata, and pool summary without decrypting or returning tokens. `/admin/auth/logout` clears all account rows and the runtime pool while relying on SQLite foreign-key cascades for usage/cookies. `/admin/auth/login-start` creates a short-lived in-memory PKCE session and returns the OpenAI authorization URL; `/admin/auth/code-relay`, `/admin/auth/callback`, and the registered `/auth/callback` redirect path exchange returned authorization codes and import tokens through the same JWT-claim validated path. `/admin/auth/device-login` starts the OpenAI OAuth device-code flow, and `/admin/auth/device-poll/{device_code}` handles pending/slow_down states or imports returned tokens without returning secrets. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Manual `POST /admin/accounts` accepts only `token` and/or `refreshToken`, rejects invalid/expired JWTs and missing ChatGPT account claims, derives metadata from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only inputs through `AppState`'s OpenAI token refresher, preserves or rotates refresh tokens, stores secrets encrypted, synchronizes the runtime account pool, updates existing accounts by `chatgpt_account_id` + `chatgpt_user_id`, and never echoes tokens. `/admin/accounts/quota-warnings` returns active warning/critical quota windows computed from cached SQLite quota JSON without contacting upstream. `/admin/accounts/import` accepts native and Sub2API OpenAI OAuth account exports, returns `sourceFormat`, and ignores proxy-only metadata. `/admin/accounts/import-cli` reads Codex CLI `auth.json` from `$CODEX_HOME` or an explicit admin fixture path, uses the same validated JWT-claim account import path as manual add, ignores caller metadata, and returns only sanitized import counts. `/admin/accounts/export` returns native or Sub2API OpenAI OAuth exports without proxy fields. `/admin/accounts/{id}/label`, `/status`, `/refresh`, `/reset-usage`, `/quota`, delete, batch-delete, batch-status, and `/admin/accounts/health-check` mutate or inspect SQLite plus runtime account pool without returning secrets; health/quota call Codex backend usage endpoints through imported account tokens and classify invalid/quota/banned/Cloudflare responses into account state or cooldown. `/admin/accounts/{id}/cookies` stores/reads/deletes encrypted per-account browser cookies. `/admin/api-keys/{id}/label`, `/status`, delete, and batch-delete mutate local client key rows used by `/v1` auth; `/admin/api-keys/export` returns metadata only without plaintext/keyHash/pepper, and `/admin/api-keys/import` rotates metadata into new local keys with plaintext returned once. These are local auth utilities, not TS provider key pool parity. `/admin/refresh-models` fetches backend model lists through imported accounts only, stores plan snapshots, and syncs runtime model-plan allowlists. Still missing settings mutation/persistence, diagnostics, and live Chat streaming transform. | +| HTTP routes | Partial | `ab0ad8f` + `3830b21` + `6179d44` + `cfec8ba` + `6341336` + `228febd` + `c2cf7a6` + `e9fdea0` + `ee0ed90` + `fdb9374` + `9ca5c7d` + `b9c30ba` + `350ffbe` + `3ecd5b6` + `3960a35` + `1738f24` + `0b6cc7e` + `f75db60` + `4b19846` + `db6cb98` + `16eb4ed` + `8af350d` + `203270f` + `960bbb2` + `72438c6` + `16938e6` + `869b6f8` + `e1f35e0` + `93cfee3` + `bfe561d` + `cf5ff0b` + `6d18d2c` + `a32a53c` + `1d6c301` + `a3b4d2b` | `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`; `cargo test --test model_catalog_test`; `cargo test --test admin_accounts_route_test`; `cargo test --test cli_auth_import_test`; `cargo test --test admin_api_keys_route_test`; `cargo test --test admin_usage_stats_route_test`; `cargo test --test admin_accounts_cookies_quota_test`; `cargo test --test v1_upstream_route_test`; `cargo test --test chat_completions_route_test`; `cargo test --test codex_websocket_test`; targeted HTTP SSE error tests before `bfe561d`; targeted local API key import/export tests before `cf5ff0b`; WebSocket fallback targeted route tests; auth status/logout targeted `cargo test --test admin_accounts_route_test`; device login targeted `cargo test --test admin_accounts_route_test admin_auth_device`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; PKCE auth-flow targeted `cargo test --test admin_accounts_route_test admin_auth_`, `cargo test --test oauth_refresh_test`, and `cargo test --test admin_accounts_route_test`; full `cargo fmt --check && cargo test && cargo clippy --all-targets --all-features --locked -- -D warnings` before `b9c30ba` and `350ffbe`; Task 5 targeted verification before `3ecd5b6` and `3960a35`; full `fmt/test/clippy` before `1738f24`; full `fmt/test/clippy` plus real-file smoke before `0b6cc7e`; full `fmt/test/clippy` before `f75db60`; full `fmt/test/clippy` before `4b19846`; full `fmt/test/clippy` before `db6cb98`; full `fmt/test/clippy` before `16eb4ed`; full `fmt/test/clippy` before `8af350d`; full `fmt/test/clippy` before `203270f`; full `fmt/test/clippy` before `960bbb2`; full `fmt/test/clippy` before `72438c6`; full `fmt/test/clippy` before `16938e6` | Adds Axum router, health route, `/v1/models`, `/v1/models/catalog`, `/v1/models/{id}`, `/v1/models/{id}/info`, `/debug/models`, `/admin/auth/status`, `/admin/auth/logout`, `/admin/auth/login-start`, `/admin/auth/code-relay`, `/admin/auth/callback`, `/auth/callback`, `/admin/auth/device-login`, `/admin/auth/device-poll/{device_code}`, `/admin/accounts`, `/admin/accounts/quota-warnings`, `/admin/accounts/export`, `/admin/accounts/batch-delete`, `/admin/accounts/batch-status`, `/admin/accounts/health-check`, `/admin/accounts/{id}`, `/admin/accounts/{id}/label`, `/admin/accounts/{id}/status`, `/admin/accounts/{id}/refresh`, `/admin/accounts/{id}/reset-usage`, `/admin/accounts/{id}/quota`, `/admin/accounts/{id}/cookies`, `/admin/accounts/import`, `/admin/accounts/import-cli`, `/admin/api-keys`, `/admin/api-keys/export`, `/admin/api-keys/import`, `/admin/api-keys/batch-delete`, `/admin/api-keys/{id}`, `/admin/api-keys/{id}/label`, `/admin/api-keys/{id}/status`, `/admin/usage-stats`, `/admin/usage-stats/summary`, `/admin/refresh-models`, `/v1/responses` HTTP SSE collection with output item/delta reconstruction plus `output_text` sync for non-streaming clients, default streaming Responses passthrough when `stream` is omitted, WebSocket-backed Responses for `previous_response_id`, explicit non-history WebSocket Responses via `use_websocket`/`useWebSocket`, non-streaming and HTTP SSE setup fallback to another imported account after 429/402/403 account-state failures, WebSocket setup fallback after classified handshake failures when no `previous_response_id` is present, Responses field parity for service tier, tool choice, parallel tool calls, text format, prompt cache key, include, client metadata, reasoning include default, and Codex context headers, plus a dedicated `/v1/chat/completions` route backed by imported Codex accounts with fallback for buffered non-streaming and client-streaming output conversion. `/v1` unauthorized and model errors remain OpenAI-compatible, cached backend model snapshots are used by `/v1/models*` when present, global request-id response headers are preserved, `gpt-5.5` remains the default/test model baseline, and `/admin/settings` supports admin-session-gated read/write access with lower camelCase envelopes. `/admin/auth/status` returns sanitized authenticated state, first account metadata, and pool summary without decrypting or returning tokens. `/admin/auth/logout` clears all account rows and the runtime pool while relying on SQLite foreign-key cascades for usage/cookies. `/admin/auth/login-start` creates a short-lived in-memory PKCE session and returns the OpenAI authorization URL; `/admin/auth/code-relay`, `/admin/auth/callback`, and the registered `/auth/callback` redirect path exchange returned authorization codes and import tokens through the same JWT-claim validated path. `/admin/auth/device-login` starts the OpenAI OAuth device-code flow, and `/admin/auth/device-poll/{device_code}` handles pending/slow_down states or imports returned tokens without returning secrets. `/admin/accounts` reads metadata only and does not decrypt tokens for list views. Manual `POST /admin/accounts` accepts only `token` and/or `refreshToken`, rejects invalid/expired JWTs and missing ChatGPT account claims, derives metadata from JWT claims, ignores caller metadata/status/label, exchanges refresh-token-only inputs through `AppState`'s OpenAI token refresher, preserves or rotates refresh tokens, stores secrets encrypted, synchronizes the runtime account pool, updates existing accounts by `chatgpt_account_id` + `chatgpt_user_id`, and never echoes tokens. `/admin/accounts/quota-warnings` returns active warning/critical quota windows computed from cached SQLite quota JSON without contacting upstream. `/admin/accounts/import` accepts native and Sub2API OpenAI OAuth account exports, returns `sourceFormat`, and ignores proxy-only metadata. `/admin/accounts/import-cli` reads Codex CLI `auth.json` from `$CODEX_HOME` or an explicit admin fixture path, uses the same validated JWT-claim account import path as manual add, ignores caller metadata, and returns only sanitized import counts. `/admin/accounts/export` returns native or Sub2API OpenAI OAuth exports without proxy fields. `/admin/accounts/{id}/label`, `/status`, `/refresh`, `/reset-usage`, `/quota`, delete, batch-delete, batch-status, and `/admin/accounts/health-check` mutate or inspect SQLite plus runtime account pool without returning secrets; health/quota call Codex backend usage endpoints through imported account tokens and classify invalid/quota/banned/Cloudflare responses into account state or cooldown. `/admin/accounts/{id}/cookies` stores/reads/deletes encrypted per-account browser cookies. `/admin/api-keys/{id}/label`, `/status`, delete, and batch-delete mutate local client key rows used by `/v1` auth; `/admin/api-keys/export` returns metadata only without plaintext/keyHash/pepper, and `/admin/api-keys/import` rotates metadata into new local keys with plaintext returned once. These are local auth utilities, not TS provider key pool parity. `/admin/refresh-models` fetches backend model lists through imported accounts only, stores plan snapshots, and syncs runtime model-plan allowlists. Still missing diagnostics and live Chat streaming transform. | | Admin quota warnings | Completed | `b879dd6` | `cargo test --test admin_accounts_cookies_quota_test quota_warnings`; `cargo test --test admin_accounts_cookies_quota_test`; `cargo fmt --check`; `cargo test`; `cargo clippy --all-targets --all-features --locked -- -D warnings` | Adds `/admin/accounts/quota-warnings`, backed by cached SQLite quota snapshots and configured warning thresholds. The route is admin-session gated, returns lower camelCase admin envelopes, and does not call upstream Codex while listing active warnings. | | 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` + `e9fdea0` | `cargo test --test http_auth_test`; `cargo test --test routes_responses_test`; `cargo test --test admin_api_keys_route_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 enabled local `Bearer cpr_...` client API keys verified from SQLite, admin session parsing ignores client API keys, and route handlers no longer own ad hoc credential parsing. | 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 3b5dedd4e..1798da3f7 100644 --- a/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md +++ b/docs/superpowers/plans/2026-06-11-codex-proxy-rs.md @@ -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. diff --git a/src/app/state.rs b/src/app/state.rs index f640aedce..f06e6dd33 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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, token_refresher: Option>, oauth_client: Option>, + local_config_path: Option, } 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, + ) -> 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, diff --git a/src/http/admin/router.rs b/src/http/admin/router.rs index ccaaece19..612730740 100644 --- a/src/http/admin/router.rs +++ b/src/http/admin/router.rs @@ -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 { 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)) diff --git a/src/http/admin/settings.rs b/src/http/admin/settings.rs index dd4cd6ec9..a47bbd497 100644 --- a/src/http/admin/settings.rs +++ b/src/http/admin/settings.rs @@ -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, + default_reasoning_effort: Option, + service_tier: Option, + model_aliases: Option>, + refresh_enabled: Option, + refresh_margin_seconds: Option, + refresh_concurrency: Option, + max_concurrent_per_account: Option, + request_interval_ms: Option, + rotation_strategy: Option, + tier_priority: Option>, + quota_refresh_interval_minutes: Option, + quota_warning_thresholds: Option, + quota_skip_exhausted: Option, + logs_enabled: Option, + logs_capacity: Option, + logs_capture_body: Option, + usage_history_retention_days: Option, +} + +impl From 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, Extension(request_id): Extension, @@ -69,9 +141,92 @@ pub async fn settings( ) -> Result { 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, + Extension(request_id): Extension, + headers: HeaderMap, + body: Bytes, +) -> Result { + 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 { + let value = serde_json::from_slice::(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, + ), + } +} diff --git a/src/service/mod.rs b/src/service/mod.rs index f0fe75900..3f44abf34 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -3,4 +3,5 @@ pub mod api_key; pub mod chat; pub mod log; pub mod responses; +pub mod settings; pub mod usage; diff --git a/src/service/settings.rs b/src/service/settings.rs new file mode 100644 index 000000000..f19d50ce7 --- /dev/null +++ b/src/service/settings.rs @@ -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>, + local_config_path: Arc, +} + +#[derive(Debug, Default)] +pub struct SettingsUpdate { + pub default_model: Option, + pub default_reasoning_effort: Option, + pub service_tier: Option, + pub model_aliases: Option>, + pub refresh_enabled: Option, + pub refresh_margin_seconds: Option, + pub refresh_concurrency: Option, + pub max_concurrent_per_account: Option, + pub request_interval_ms: Option, + pub rotation_strategy: Option, + pub tier_priority: Option>, + pub quota_refresh_interval_minutes: Option, + pub quota_warning_thresholds: Option, + pub quota_skip_exhausted: Option, + pub logs_enabled: Option, + pub logs_capacity: Option, + pub logs_capture_body: Option, + pub usage_history_retention_days: Option, +} + +#[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) -> 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 { + 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, +) -> Result, 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) -> Result, 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 { + 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 { + 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 { + if value == 0 { + Err(invalid_field(field, "must be greater than 0")) + } else { + Ok(value) + } +} + +fn positive_u64(field: &str, value: u64) -> Result { + if value == 0 { + Err(invalid_field(field, "must be greater than 0")) + } else { + Ok(value) + } +} + +fn positive_usize(field: &str, value: usize) -> Result { + if value == 0 { + Err(invalid_field(field, "must be greater than 0")) + } else { + Ok(value) + } +} + +fn invalid_field(field: &str, message: impl Into) -> SettingsServiceError { + SettingsServiceError::InvalidField { + field: field.to_string(), + message: message.into(), + } +} diff --git a/tests/admin_settings_route_test.rs b/tests/admin_settings_route_test.rs index cff10d9b4..c3e9e5509 100644 --- a/tests/admin_settings_route_test.rs +++ b/tests/admin_settings_route_test.rs @@ -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();