diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..5a9134515 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +warnings = "deny" diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml index 25d45dde4..8d383ffde 100644 --- a/.github/workflows/_quality.yml +++ b/.github/workflows/_quality.yml @@ -24,7 +24,6 @@ jobs: CARGO_TERM_COLOR: always CPR_TEST_DATABASE_URL: postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy CPR_TEST_REDIS_URL: redis://127.0.0.1:6379 - RUSTFLAGS: -D warnings services: postgres: image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818 @@ -56,7 +55,7 @@ jobs: ref: ${{ inputs.ref }} - name: Setup Rust - uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95 + uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97 with: components: rustfmt,clippy @@ -71,7 +70,7 @@ jobs: - name: Rust clippy working-directory: backend - run: cargo clippy --all-targets --all-features --locked -- -D warnings + run: cargo clippy --all-targets --all-features --locked - name: Rust tests working-directory: backend diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b99d034b..8fe3dffe4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,7 +205,7 @@ jobs: -v "$HOME/.cargo:/cargo" \ -v "$PWD/..:/workspace" \ -w /workspace/backend \ - rust:1.95-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1 \ + rust:1.97-bookworm@sha256:7d0723df719e7f213b69dc7c8c595985c3f4b060cfbee4f7bc0e347a86fe3b6a \ cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}" working-directory: backend @@ -239,7 +239,7 @@ jobs: ref: ${{ needs.release-info.outputs.git_sha }} - name: Setup Rust - uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95 + uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 7629bc213..01a071f3e 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -26,7 +26,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Setup Rust - uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95 + uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97 - name: Cache Rust uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 diff --git a/.gitignore b/.gitignore index c619267e2..3b25d6ca9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,43 +1,54 @@ -# Rust build output. +# Rust /backend/target/ -/target/ -/dist/ -/package/ -/checksums.txt -/codex-proxy-rs_*.tar.gz -/codex-proxy-rs_*.zip +*.profraw +*.profdata -# Frontend dependencies and build output. +# Frontend /frontend/node_modules/ /frontend/dist/ -.pnpm-store +/frontend/.vite/ +/.pnpm-store/ +/frontend/.pnpm-store/ +*.tsbuildinfo -# Application runtime state. -*.runtime +# Release artifacts +/dist/ + +# Runtime data +/.runtime/ *.sqlite *.sqlite3 +*.sqlite-* *.db *.db-shm *.db-wal *.log -# Local configuration. -/local.yaml -/local.yml -/.env +# Local configuration and secrets +.env +.env.* +!.env.example +!**/.env.example +/.envrc /config.yaml /backend/config.yaml -/deploy/.env -# Local workspaces and tooling state. +# Editors and operating systems +/.idea/ +/.vscode/ +.DS_Store +Thumbs.db +*.swp +*.swo +*~ + +# Local tools and worktrees +/.codegraph/ +/.codex-ws-audit/ +/.agents/ +/.reasonix/ /.worktrees/ /worktrees/ -/.codex-ws-audit/ -/.codegraph/ +/.cargo/* +!/.cargo/config.toml *.tmp -*.swp -*~ -*.codegraph -/.agents/ -/.cargo/ -/.reasonix/ diff --git a/README.md b/README.md index 14bec442a..f1d24f6dc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

Codex Proxy RS

OpenAI Responses 兼容的 Codex 多账号网关

- Rust 1.95 + Rust 1.97 Axum 0.8 Vue 3.5 Vite 8 diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5c7406a18..4a5473e72 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "codex-proxy-rs" -edition = "2021" +edition = "2024" license = "MIT" publish = false -rust-version = "1.95" +rust-version = "1.97" [dependencies] argon2 = "0.5.3" diff --git a/backend/rust-toolchain.toml b/backend/rust-toolchain.toml index ac3be6b9d..e8fcdf698 100644 --- a/backend/rust-toolchain.toml +++ b/backend/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95" +channel = "1.97" +components = ["clippy", "rustfmt"] profile = "minimal" -components = ["rustfmt", "clippy"] diff --git a/backend/src/api/admin/accounts_routes/lifecycle_routes.rs b/backend/src/api/admin/accounts_routes/lifecycle_routes.rs index 423eac433..f4e57cc30 100644 --- a/backend/src/api/admin/accounts_routes/lifecycle_routes.rs +++ b/backend/src/api/admin/accounts_routes/lifecycle_routes.rs @@ -33,12 +33,12 @@ pub async fn health_check_accounts( "stagger_ms must be between 500 and 30000", )); } - if let Some(concurrency) = payload.concurrency { - if !(1..=10).contains(&concurrency) { - return Err(AdminError::bad_request( - "concurrency must be between 1 and 10", - )); - } + if let Some(concurrency) = payload.concurrency + && !(1..=10).contains(&concurrency) + { + return Err(AdminError::bad_request( + "concurrency must be between 1 and 10", + )); } match state .services diff --git a/backend/src/api/admin/accounts_routes/mod.rs b/backend/src/api/admin/accounts_routes/mod.rs index 3b7f846f0..fb1330a8f 100644 --- a/backend/src/api/admin/accounts_routes/mod.rs +++ b/backend/src/api/admin/accounts_routes/mod.rs @@ -1,14 +1,14 @@ //! 账号管理 HTTP 处理器。 use axum::{ + Json, body::Body, extract::{Query, State}, http::{ - header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, StatusCode, + header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE}, }, response::{IntoResponse, Response}, - Json, }; use std::collections::{HashMap, HashSet}; @@ -17,22 +17,22 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ + api::AppState, api::admin::{ response::{ - parse_editable_update, AdminEnvelope, AdminError, AdminResponse, BatchDeleteData, - EditableUpdateMessages, PageMeta, ADMIN_OK_CODE, ADMIN_OK_MESSAGE, + ADMIN_OK_CODE, ADMIN_OK_MESSAGE, AdminEnvelope, AdminError, AdminResponse, + BatchDeleteData, EditableUpdateMessages, PageMeta, parse_editable_update, }, session::AdminAuth, }, - api::AppState, fleet::{ account::AccountStatus, manage::{ - quota_view::{ - quota_data, AccountQuotaData, AccountQuotaUsageWindow, AccountQuotaWindowLocalUsage, - }, AccountHealthCheck, AccountManageError, AccountRefreshOutcome, AccountRefreshResult, AccountUpdate, ManagedAccount, OAuthExchangeInput, + quota_view::{ + AccountQuotaData, AccountQuotaUsageWindow, AccountQuotaWindowLocalUsage, quota_data, + }, }, refresh::token_refresh_status_eligible, }, diff --git a/backend/src/api/admin/auth_routes.rs b/backend/src/api/admin/auth_routes.rs index 4c6c9b5f5..3ccf41605 100644 --- a/backend/src/api/admin/auth_routes.rs +++ b/backend/src/api/admin/auth_routes.rs @@ -1,21 +1,21 @@ //! 管理端认证路由。 use axum::{ - extract::State, - http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Response}, Extension, Json, + extract::State, + http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE}, + response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use crate::{ api::{ + AppState, admin::{ response::{AdminEnvelope, AdminError, AdminResponse}, - session::{admin_session_cookie, ADMIN_SESSION_COOKIE}, + session::{ADMIN_SESSION_COOKIE, admin_session_cookie}, }, middleware::request_id::ClientIp, - AppState, }, auth::types::SessionError, infra::time::china_rfc3339, @@ -107,15 +107,14 @@ pub(crate) async fn logout( State(state): State, headers: HeaderMap, ) -> Result { - if let Some(session_id) = admin_session_cookie(&headers) { - if let Err(error) = state + if let Some(session_id) = admin_session_cookie(&headers) + && let Err(error) = state .services .admin_sessions .delete_session(&session_id) .await - { - tracing::warn!(error = %error, "failed to revoke admin session during logout"); - } + { + tracing::warn!(error = %error, "failed to revoke admin session during logout"); } let mut response = AdminResponse::new( diff --git a/backend/src/api/admin/dashboard_routes.rs b/backend/src/api/admin/dashboard_routes.rs index acb4c5297..ee1f5b176 100644 --- a/backend/src/api/admin/dashboard_routes.rs +++ b/backend/src/api/admin/dashboard_routes.rs @@ -12,15 +12,15 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ + api::AppState, api::admin::{ response::{AdminEnvelope, AdminError, AdminResponse}, session::AdminAuth, usage_routes::{ - usage_cost_details, usage_record_models, usage_token_details, - UsageRecordCostDetailsData, UsageRecordTokenDetailsData, + UsageRecordCostDetailsData, UsageRecordTokenDetailsData, usage_cost_details, + usage_record_models, usage_token_details, }, }, - api::AppState, fleet::{ account::{Account, AccountStatus}, pool::AccountCapacitySummary, @@ -36,13 +36,13 @@ use crate::{ telemetry::{ account_usage::query::AccountUsageTimeBucket, dashboard::{ - dashboard_cards, dashboard_health_timeline_data, dashboard_trend_data, DashboardAccountCounts, DashboardCardsData, DashboardHealthTimelineData, - DashboardTrendData, DashboardTrendKind, + DashboardTrendData, DashboardTrendKind, dashboard_cards, + dashboard_health_timeline_data, dashboard_trend_data, }, usage::{ query::{UsageQueryFilter, UsageRecordAccountUsage}, - types::{metadata_string, UsageRecord}, + types::{UsageRecord, metadata_string}, }, }, upstream::openai::fingerprint::Fingerprint, diff --git a/backend/src/api/admin/keys_routes.rs b/backend/src/api/admin/keys_routes.rs index b15dddd55..28b7029f0 100644 --- a/backend/src/api/admin/keys_routes.rs +++ b/backend/src/api/admin/keys_routes.rs @@ -1,23 +1,23 @@ //! 管理端 v1 接口访问 Key 处理器(列表、创建、生命周期)。 use axum::{ + Json, extract::{Query, State}, http::StatusCode, response::IntoResponse, - Json, }; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::{ + api::AppState, api::admin::response::{ - parse_editable_update, AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse, - BatchDeleteData, EditableUpdateMessages, + AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse, BatchDeleteData, + EditableUpdateMessages, parse_editable_update, }, api::admin::session::AdminAuth, - api::AppState, infra::{ - json::{clamp_limit, clamp_page, NumberedPage}, + json::{NumberedPage, clamp_limit, clamp_page}, time::{china_relative_time_str, china_rfc3339_str}, }, keys::types::{KeyManageError, ManagedClientApiKey}, diff --git a/backend/src/api/admin/ops_routes.rs b/backend/src/api/admin/ops_routes.rs index 80df4cbc7..8d7a2bf28 100644 --- a/backend/src/api/admin/ops_routes.rs +++ b/backend/src/api/admin/ops_routes.rs @@ -8,13 +8,13 @@ use axum::{ use serde::{Deserialize, Serialize}; use crate::{ + api::AppState, api::admin::{ response::{AdminError, AdminPageEnvelope, AdminResponse}, session::AdminAuth, }, - api::AppState, infra::{ - json::{clamp_limit, clamp_page, NumberedPage}, + json::{NumberedPage, clamp_limit, clamp_page}, time::china_datetime, }, telemetry::{ops::query::OpsQueryError, ops::store::OpsErrorFilter, ops::types::OpsErrorLog}, diff --git a/backend/src/api/admin/response.rs b/backend/src/api/admin/response.rs index 42deef828..1f852a8f5 100644 --- a/backend/src/api/admin/response.rs +++ b/backend/src/api/admin/response.rs @@ -1,10 +1,10 @@ //! 管理端响应封装与 From 转换。 -use crate::infra::json::{total_pages, NumberedPage}; +use crate::infra::json::{NumberedPage, total_pages}; use axum::{ + Json, http::StatusCode, response::{IntoResponse, Response}, - Json, }; use serde::{Deserialize, Serialize}; use serde_json::Value; diff --git a/backend/src/api/admin/router.rs b/backend/src/api/admin/router.rs index 397b2e43e..8764e5aee 100644 --- a/backend/src/api/admin/router.rs +++ b/backend/src/api/admin/router.rs @@ -1,11 +1,11 @@ //! 管理端路由。 use axum::{ - http::{header, HeaderValue}, + Router, + http::{HeaderValue, header}, middleware, response::Response, routing::{get, post}, - Router, }; use crate::api::AppState; diff --git a/backend/src/api/admin/session.rs b/backend/src/api/admin/session.rs index 17d87c29a..9a36f3de0 100644 --- a/backend/src/api/admin/session.rs +++ b/backend/src/api/admin/session.rs @@ -2,10 +2,10 @@ use axum::{ extract::FromRequestParts, - http::{request::Parts, HeaderMap}, + http::{HeaderMap, request::Parts}, }; -use crate::api::{admin::response::AdminError, AppState}; +use crate::api::{AppState, admin::response::AdminError}; pub(super) const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session"; diff --git a/backend/src/api/admin/settings_routes.rs b/backend/src/api/admin/settings_routes.rs index ffff58b07..c313419ca 100644 --- a/backend/src/api/admin/settings_routes.rs +++ b/backend/src/api/admin/settings_routes.rs @@ -6,11 +6,11 @@ use serde_json::Value; use std::collections::BTreeMap; use crate::{ + api::AppState, api::admin::{ response::{AdminEnvelope, AdminError, AdminResponse}, session::AdminAuth, }, - api::AppState, settings::{SettingsError, SettingsPatch, SettingsSnapshot, SettingsValidationError}, }; diff --git a/backend/src/api/admin/system_routes.rs b/backend/src/api/admin/system_routes.rs index bf062c0e2..df9d58caa 100644 --- a/backend/src/api/admin/system_routes.rs +++ b/backend/src/api/admin/system_routes.rs @@ -6,24 +6,20 @@ use axum::{ extract::{Json, Query, State}, http::StatusCode, response::{ - sse::{Event, KeepAlive, Sse}, IntoResponse, + sse::{Event, KeepAlive, Sse}, }, }; use futures::Stream; use serde::Deserialize; use crate::{ + api::AppState, api::admin::{ response::{AdminEnvelope, AdminError, AdminResponse}, session::AdminAuth, }, - api::AppState, - update::{ - service::{self, RestartAction}, - state::operation_id, - types::UpdateError, - }, + update::{service::RestartAction, state::operation_id, types::UpdateError}, }; #[derive(Debug, Deserialize)] @@ -38,20 +34,30 @@ pub(crate) struct UpdateRequest { target_version: String, } -pub(crate) async fn version(_auth: AdminAuth) -> Result { +pub(crate) async fn version( + State(state): State, + _auth: AdminAuth, +) -> Result { Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(service::version_data().await), + AdminEnvelope::ok(state.services.system_update.version_data().await), )) } pub(crate) async fn update_detail( + State(state): State, _auth: AdminAuth, Query(query): Query, ) -> Result { Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(service::update_detail(query.refresh.unwrap_or(false)).await), + AdminEnvelope::ok( + state + .services + .system_update + .update_detail(query.refresh.unwrap_or(false)) + .await, + ), )) } @@ -59,7 +65,7 @@ pub(crate) async fn update_event_stream( State(state): State, _auth: AdminAuth, ) -> Result>>, AdminError> { - let receiver = service::subscribe_update_events(); + let receiver = state.services.system_update.subscribe_update_events(); let shutdown = state.services.process_control.subscribe_shutdown(); let stream = futures::stream::unfold( (receiver, shutdown, false), @@ -90,11 +96,15 @@ pub(crate) async fn update_event_stream( } pub(crate) async fn perform_update( + State(state): State, _auth: AdminAuth, payload: Option>, ) -> Result { let target = payload.map(|Json(payload)| payload.target_version); - let result = service::perform_update(target) + let result = state + .services + .system_update + .perform_update(target) .await .map_err(update_error)?; Ok(AdminResponse::new( @@ -103,16 +113,31 @@ pub(crate) async fn perform_update( )) } -pub(crate) async fn update_status(_auth: AdminAuth) -> Result { - let status = service::update_status().map_err(update_error)?; +pub(crate) async fn update_status( + State(state): State, + _auth: AdminAuth, +) -> Result { + let status = state + .services + .system_update + .update_status() + .map_err(update_error)?; Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(status), )) } -pub(crate) async fn rollback(_auth: AdminAuth) -> Result { - let operation_id = service::rollback().await.map_err(update_error)?; +pub(crate) async fn rollback( + State(state): State, + _auth: AdminAuth, +) -> Result { + let operation_id = state + .services + .system_update + .rollback() + .await + .map_err(update_error)?; Ok(AdminResponse::new( StatusCode::OK, AdminEnvelope::ok(serde_json::json!({ @@ -127,7 +152,11 @@ pub(crate) async fn restart( State(state): State, _auth: AdminAuth, ) -> Result { - let plan = service::restart_plan().map_err(update_error)?; + let plan = state + .services + .system_update + .restart_plan() + .map_err(update_error)?; let message = plan.message; let process_control = state.services.process_control.clone(); tokio::spawn(async move { diff --git a/backend/src/api/admin/usage_routes.rs b/backend/src/api/admin/usage_routes.rs index bd7ef2605..c19b832f9 100644 --- a/backend/src/api/admin/usage_routes.rs +++ b/backend/src/api/admin/usage_routes.rs @@ -10,26 +10,26 @@ use axum::{ use serde::{Deserialize, Serialize}; use crate::{ + api::AppState, api::admin::{ response::{AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse}, session::AdminAuth, }, - api::AppState, infra::{ format::{ format_compact_number, format_cost, format_duration_ms, format_duration_ms_f64, format_multiplier, format_number, format_percent, format_token_price, format_tokens, }, - json::{clamp_limit, clamp_page, NumberedPage}, + json::{NumberedPage, clamp_limit, clamp_page}, time::china_datetime, }, telemetry::{ usage::query::{ - usage_record_cost_details, UsageQueryError, UsageQueryFilter, UsageRecordBreakdown, - UsageRecordEndpointSource, UsageRecordModelSource, UsageRecordSummary, - UsageRecordTrendPoint, + UsageQueryError, UsageQueryFilter, UsageRecordBreakdown, UsageRecordEndpointSource, + UsageRecordModelSource, UsageRecordSummary, UsageRecordTrendPoint, + usage_record_cost_details, }, - usage::types::{metadata_string, UsageRecord}, + usage::types::{UsageRecord, metadata_string}, }, }; diff --git a/backend/src/api/assets.rs b/backend/src/api/assets.rs index 4e8ce7f4e..1449c4121 100644 --- a/backend/src/api/assets.rs +++ b/backend/src/api/assets.rs @@ -4,10 +4,10 @@ use std::{ }; use axum::{ + Json, Router, body::Body, http::{Request, StatusCode}, response::{IntoResponse, Response}, - Json, Router, }; use serde_json::json; use tower::service_fn; diff --git a/backend/src/api/client/auth.rs b/backend/src/api/client/auth.rs index 7ccaccb88..89c07aa69 100644 --- a/backend/src/api/client/auth.rs +++ b/backend/src/api/client/auth.rs @@ -2,7 +2,7 @@ //! //! 提供 Bearer token 提取与客户端 API key 校验。 -use axum::http::{header::AUTHORIZATION, HeaderMap}; +use axum::http::{HeaderMap, header::AUTHORIZATION}; use crate::api::AppState; diff --git a/backend/src/api/client/errors.rs b/backend/src/api/client/errors.rs index 298014914..687045e83 100644 --- a/backend/src/api/client/errors.rs +++ b/backend/src/api/client/errors.rs @@ -1,11 +1,11 @@ //! OpenAI 路由共享错误响应。 use axum::{ + Json, http::StatusCode, response::{IntoResponse, Response}, - Json, }; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use crate::{ dispatch::errors::{DispatchFailureClass, ResponseDispatchError}, @@ -117,12 +117,10 @@ pub fn responses_dispatch_error_response(error: ResponseDispatchError) -> Respon pub fn responses_dispatch_error_response_ref(error: &ResponseDispatchError) -> Response { if let ResponseDispatchError::Upstream(CodexClientError::Upstream { status, body, .. }) = error + && status.is_client_error() + && let Ok(body) = serde_json::from_str::(body) { - if status.is_client_error() { - if let Ok(body) = serde_json::from_str::(body) { - return (*status, Json(body)).into_response(); - } - } + return (*status, Json(body)).into_response(); } match error { diff --git a/backend/src/api/client/models.rs b/backend/src/api/client/models.rs index 951ed2d3e..8d1a569c5 100644 --- a/backend/src/api/client/models.rs +++ b/backend/src/api/client/models.rs @@ -1,15 +1,15 @@ //! OpenAI 模型列表以及模型处理器。 use axum::{ + Json, extract::{Path, State}, http::{HeaderMap, StatusCode}, response::IntoResponse, - Json, }; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use crate::{ - api::client::auth::authorize_client_api_key, api::AppState, models::catalog::ModelCatalog, + api::AppState, api::client::auth::authorize_client_api_key, models::catalog::ModelCatalog, }; use super::errors::{missing_client_api_key_response, model_not_found_response}; diff --git a/backend/src/api/client/responses.rs b/backend/src/api/client/responses.rs index 2358230d3..d7e069caf 100644 --- a/backend/src/api/client/responses.rs +++ b/backend/src/api/client/responses.rs @@ -2,17 +2,17 @@ //! 做最小 patch 后交给调度层,请求语义原样透传上游。 use axum::{ + Extension, Json, body::{Body, Bytes}, extract::State, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, - Extension, Json, }; use serde_json::{Map, Value}; use crate::{ - api::middleware::request_id::{ClientIp, RequestId}, api::AppState, + api::middleware::request_id::{ClientIp, RequestId}, dispatch::{errors::ResponseDispatchError, service::ResponseDispatchStream}, upstream::openai::protocol::{ responses::{CodexCompactRequest, CodexResponsesRequest}, @@ -449,15 +449,11 @@ pub(crate) fn user_agent_from_headers(headers: &HeaderMap) -> Option { } fn openai_subagent_from_headers(headers: &HeaderMap) -> Option { - header_string(headers, OPENAI_SUBAGENT_HEADER).and_then(|value| { - if matches!( + header_string(headers, OPENAI_SUBAGENT_HEADER).filter(|value| { + matches!( value.as_str(), "review" | "compact" | "memory_consolidation" | "collab_spawn" - ) { - Some(value) - } else { - None - } + ) }) } diff --git a/backend/src/api/client/router.rs b/backend/src/api/client/router.rs index 58d742507..a74ca88e7 100644 --- a/backend/src/api/client/router.rs +++ b/backend/src/api/client/router.rs @@ -1,9 +1,9 @@ //! OpenAI 兼容 API 路由。 use axum::{ + Router, extract::DefaultBodyLimit, routing::{get, post}, - Router, }; use crate::api::AppState; diff --git a/backend/src/api/client/sse.rs b/backend/src/api/client/sse.rs index d1cdd6d59..f64cae753 100644 --- a/backend/src/api/client/sse.rs +++ b/backend/src/api/client/sse.rs @@ -5,8 +5,8 @@ use axum::{ body::Body, http::{ - header::{CACHE_CONTROL, CONTENT_TYPE}, StatusCode, + header::{CACHE_CONTROL, CONTENT_TYPE}, }, response::{IntoResponse, Response}, }; diff --git a/backend/src/api/middleware/request_id.rs b/backend/src/api/middleware/request_id.rs index 82f21edde..4090c2736 100644 --- a/backend/src/api/middleware/request_id.rs +++ b/backend/src/api/middleware/request_id.rs @@ -4,7 +4,7 @@ use std::net::{IpAddr, SocketAddr}; use std::sync::LazyLock; use axum::{ - extract::{connect_info::ConnectInfo, Request}, + extract::{Request, connect_info::ConnectInfo}, http::{HeaderMap, HeaderValue}, middleware::Next, response::Response, diff --git a/backend/src/api/middleware/trace.rs b/backend/src/api/middleware/trace.rs index e9b9cea5a..3ae408c58 100644 --- a/backend/src/api/middleware/trace.rs +++ b/backend/src/api/middleware/trace.rs @@ -10,7 +10,7 @@ use tower_http::{ classify::{ServerErrorsAsFailures, ServerErrorsFailureClass, SharedClassifier}, trace::TraceLayer, }; -use tracing::{debug, info, warn, Span}; +use tracing::{Span, debug, info, warn}; use crate::api::middleware::request_id::{ClientIp, RequestId}; diff --git a/backend/src/api/router.rs b/backend/src/api/router.rs index b71f558a1..127dd658a 100644 --- a/backend/src/api/router.rs +++ b/backend/src/api/router.rs @@ -6,7 +6,7 @@ use std::{ }; use async_trait::async_trait; -use axum::{extract::State, http::StatusCode, middleware, routing::get, Router}; +use axum::{Router, extract::State, http::StatusCode, middleware, routing::get}; use crate::{ api::{ @@ -26,6 +26,7 @@ use crate::{ account_usage::query::AccountUsageQueryService, ops::query::OpsQueryService, usage::query::UsageQueryService, }, + update::service::SystemUpdateService, upstream::openai::{fingerprint::RuntimeFingerprint, token_client::OpenAiTokenClient}, }; @@ -49,6 +50,7 @@ pub struct ApiServices { pub session_affinity: Arc, pub fingerprint: RuntimeFingerprint, pub process_control: Arc, + pub system_update: Arc, } /// 由 bootstrap 实现的进程生命周期控制端口。 diff --git a/backend/src/bootstrap/config.rs b/backend/src/bootstrap/config.rs index 667507b7d..07452a645 100644 --- a/backend/src/bootstrap/config.rs +++ b/backend/src/bootstrap/config.rs @@ -1,6 +1,6 @@ use std::{collections::BTreeMap, env, path::Path}; -use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; use serde::{Deserialize, Serialize}; use url::Url; diff --git a/backend/src/bootstrap/import_sqlite/core.rs b/backend/src/bootstrap/import_sqlite/core.rs index ac3e854ef..33bb98e2b 100644 --- a/backend/src/bootstrap/import_sqlite/core.rs +++ b/backend/src/bootstrap/import_sqlite/core.rs @@ -1,11 +1,11 @@ use futures::TryStreamExt; -use sqlx::{types::Json, Postgres, Row, SqlitePool, Transaction}; +use sqlx::{Postgres, Row, SqlitePool, Transaction, types::Json}; use crate::infra::identity::hash_credential; use super::{ - parse_cookie_expiration, parse_json, parse_optional_json, parse_optional_timestamp, - parse_timestamp, ImportSqliteError, ImportSqliteReport, + ImportSqliteError, ImportSqliteReport, parse_cookie_expiration, parse_json, + parse_optional_json, parse_optional_timestamp, parse_timestamp, }; pub(super) async fn import_core_tables( diff --git a/backend/src/bootstrap/import_sqlite/mod.rs b/backend/src/bootstrap/import_sqlite/mod.rs index 9a1009dc6..68e15b701 100644 --- a/backend/src/bootstrap/import_sqlite/mod.rs +++ b/backend/src/bootstrap/import_sqlite/mod.rs @@ -8,8 +8,8 @@ use std::{collections::BTreeMap, fmt, path::Path}; use chrono::{DateTime, Utc}; use serde_json::Value; use sqlx::{ - sqlite::{SqliteConnectOptions, SqlitePoolOptions}, PgPool, Postgres, SqlitePool, Transaction, + sqlite::{SqliteConnectOptions, SqlitePoolOptions}, }; use thiserror::Error; diff --git a/backend/src/bootstrap/import_sqlite/telemetry.rs b/backend/src/bootstrap/import_sqlite/telemetry.rs index 389967e42..a34b28402 100644 --- a/backend/src/bootstrap/import_sqlite/telemetry.rs +++ b/backend/src/bootstrap/import_sqlite/telemetry.rs @@ -1,9 +1,9 @@ use futures::TryStreamExt; use serde_json::Value; -use sqlx::{types::Json, Postgres, Row, SqlitePool, Transaction}; +use sqlx::{Postgres, Row, SqlitePool, Transaction, types::Json}; use super::{ - normalized_dimension, parse_json, parse_timestamp, ImportSqliteError, ImportSqliteReport, + ImportSqliteError, ImportSqliteReport, normalized_dimension, parse_json, parse_timestamp, }; pub(super) async fn import_telemetry_tables( diff --git a/backend/src/bootstrap/services.rs b/backend/src/bootstrap/services.rs index 6a4a35699..7790789ba 100644 --- a/backend/src/bootstrap/services.rs +++ b/backend/src/bootstrap/services.rs @@ -26,7 +26,7 @@ use crate::{ infra::{ database::connect, identity::AccountPseudonymizer, - logging::{init_tracing, LogError, LogGuard, RotationConfig, TracingConfig}, + logging::{LogError, LogGuard, RotationConfig, TracingConfig, init_tracing}, paths::{ensure_data_dir, load_or_create_identity_secret}, redis::RedisConnection, }, @@ -36,7 +36,7 @@ use crate::{ store::{ModelSnapshotStore, RedisModelSnapshotStore}, types::ModelConfig, }, - settings::{service::SettingsService, SettingsSnapshot}, + settings::{SettingsSnapshot, service::SettingsService}, telemetry::{ account_usage::query::AccountUsageQueryService, account_usage::store::{AccountUsageStore, PgAccountUsageStore}, @@ -45,14 +45,15 @@ use crate::{ ops::store::PgOpsErrorLogStore, recorder::Recorder, usage::query::UsageQueryService, - usage::store::{PgUsageRecordStore, DEFAULT_USAGE_RECORD_CAPTURE_BODY}, + usage::store::{DEFAULT_USAGE_RECORD_CAPTURE_BODY, PgUsageRecordStore}, }, + update::service::SystemUpdateService, upstream::openai::{ fingerprint::{Fingerprint, PgFingerprintStore, RuntimeFingerprint}, - token_client::{default_openai_token_client, OpenAiTokenClient, TokenClientConfig}, + token_client::{OpenAiTokenClient, TokenClientConfig, default_openai_token_client}, transport::{ - build_reqwest_client, tls::CustomCaError, CodexBackendClient, CodexModelCatalogClient, - CodexWebSocketPool, CodexWebSocketPoolConfig, + CodexBackendClient, CodexModelCatalogClient, CodexWebSocketPool, + CodexWebSocketPoolConfig, build_reqwest_client, tls::CustomCaError, }, }, }; @@ -117,6 +118,7 @@ pub struct Services { pub websocket_pool: Option>, pub fingerprint: RuntimeFingerprint, pub account_pseudonymizer: Arc, + pub system_update: Arc, pub background_tasks: BackgroundTaskStores, pub(crate) account_pool_static: AccountPoolStaticSettings, } @@ -304,6 +306,7 @@ impl Services { account_identity, cloudflare: cloudflare_recovery, })); + let system_update = Arc::new(SystemUpdateService::from_env()); Ok(Self { database, @@ -327,6 +330,7 @@ impl Services { websocket_pool, fingerprint, account_pseudonymizer, + system_update, background_tasks: stores, account_pool_static, }) @@ -362,6 +366,7 @@ impl From<&Services> for crate::api::router::ApiServices { session_affinity: services.session_affinity.clone(), fingerprint: services.fingerprint.clone(), process_control: Arc::new(crate::bootstrap::shutdown::RuntimeProcessControl), + system_update: services.system_update.clone(), } } } diff --git a/backend/src/bootstrap/shutdown.rs b/backend/src/bootstrap/shutdown.rs index df18d9890..5644f1170 100644 --- a/backend/src/bootstrap/shutdown.rs +++ b/backend/src/bootstrap/shutdown.rs @@ -3,8 +3,8 @@ use std::{ path::PathBuf, sync::{ - atomic::{AtomicBool, Ordering}, OnceLock, + atomic::{AtomicBool, Ordering}, }, }; diff --git a/backend/src/bootstrap/tasks/cleanup.rs b/backend/src/bootstrap/tasks/cleanup.rs index 05ae4c7e4..a80960d0f 100644 --- a/backend/src/bootstrap/tasks/cleanup.rs +++ b/backend/src/bootstrap/tasks/cleanup.rs @@ -7,7 +7,7 @@ use tracing::{debug, info, warn}; use super::{ coordinator::SchedulerHandle, - periodic::{spawn_periodic_task, PeriodicTaskConfig, PeriodicTaskRunner}, + periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task}, }; /// 支持按时间清理过期记录的存储。 diff --git a/backend/src/bootstrap/tasks/coordinator.rs b/backend/src/bootstrap/tasks/coordinator.rs index 33572d4e5..fe3c5be98 100644 --- a/backend/src/bootstrap/tasks/coordinator.rs +++ b/backend/src/bootstrap/tasks/coordinator.rs @@ -7,8 +7,8 @@ use tokio::task::JoinHandle; use super::{ cookie_cleanup::CookieCleanupTask, fingerprint_update::{ - start_fingerprint_update_task, CODEX_DESKTOP_APPCAST_URL, - DEFAULT_EXTRACTED_FINGERPRINT_PATH, + CODEX_DESKTOP_APPCAST_URL, DEFAULT_EXTRACTED_FINGERPRINT_PATH, + start_fingerprint_update_task, }, model_refresh::ModelRefreshTask, quota_refresh::QuotaRefreshTask, diff --git a/backend/src/bootstrap/tasks/fingerprint_update.rs b/backend/src/bootstrap/tasks/fingerprint_update.rs index 452ee3ba8..b443946d4 100644 --- a/backend/src/bootstrap/tasks/fingerprint_update.rs +++ b/backend/src/bootstrap/tasks/fingerprint_update.rs @@ -5,12 +5,12 @@ use std::path::PathBuf; use tracing::{info, warn}; use crate::upstream::openai::fingerprint::{ - PgFingerprintStore, RuntimeFingerprint, UpdateChecker, APPCAST_POLL_INTERVAL, + APPCAST_POLL_INTERVAL, PgFingerprintStore, RuntimeFingerprint, UpdateChecker, }; use super::{ coordinator::SchedulerHandle, - periodic::{spawn_periodic_task, PeriodicTaskConfig, PeriodicTaskRunner}, + periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task}, }; /// Codex Desktop 官方 appcast 地址。 diff --git a/backend/src/bootstrap/tasks/model_refresh.rs b/backend/src/bootstrap/tasks/model_refresh.rs index 94a005845..fa874c9f4 100644 --- a/backend/src/bootstrap/tasks/model_refresh.rs +++ b/backend/src/bootstrap/tasks/model_refresh.rs @@ -15,7 +15,7 @@ use crate::models::service::{ use super::{ coordinator::SchedulerHandle, - periodic::{spawn_periodic_task, PeriodicTaskConfig, PeriodicTaskRunner, PeriodicTaskStartup}, + periodic::{PeriodicTaskConfig, PeriodicTaskRunner, PeriodicTaskStartup, spawn_periodic_task}, }; /// 模型刷新任务接线器。 diff --git a/backend/src/bootstrap/tasks/quota_refresh.rs b/backend/src/bootstrap/tasks/quota_refresh.rs index 975c03b6f..ffd52d4a5 100644 --- a/backend/src/bootstrap/tasks/quota_refresh.rs +++ b/backend/src/bootstrap/tasks/quota_refresh.rs @@ -19,7 +19,7 @@ use crate::{ use super::{ coordinator::SchedulerHandle, - periodic::{spawn_periodic_task, PeriodicTaskConfig, PeriodicTaskRunner}, + periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task}, }; /// 主动配额刷新后台任务。 diff --git a/backend/src/bootstrap/tasks/retention_trim.rs b/backend/src/bootstrap/tasks/retention_trim.rs index b21df93f0..8999a7818 100644 --- a/backend/src/bootstrap/tasks/retention_trim.rs +++ b/backend/src/bootstrap/tasks/retention_trim.rs @@ -13,7 +13,7 @@ use crate::{ use super::{ coordinator::SchedulerHandle, - periodic::{spawn_periodic_task, PeriodicTaskConfig, PeriodicTaskRunner, TaskFuture}, + periodic::{PeriodicTaskConfig, PeriodicTaskRunner, TaskFuture, spawn_periodic_task}, }; const RETENTION_TRIM_INTERVAL_SECS: u64 = 60 * 60; diff --git a/backend/src/bootstrap/tasks/token_refresh.rs b/backend/src/bootstrap/tasks/token_refresh.rs index 9d686a831..3c02ad945 100644 --- a/backend/src/bootstrap/tasks/token_refresh.rs +++ b/backend/src/bootstrap/tasks/token_refresh.rs @@ -10,7 +10,7 @@ use crate::upstream::openai::token_client::TokenRefresher; use super::{ coordinator::SchedulerHandle, - periodic::{spawn_periodic_task, PeriodicTaskConfig, PeriodicTaskRunner}, + periodic::{PeriodicTaskConfig, PeriodicTaskRunner, spawn_periodic_task}, }; const DEFAULT_INTERVAL_SECS: u64 = 60; diff --git a/backend/src/dispatch/affinity/resolve.rs b/backend/src/dispatch/affinity/resolve.rs index 6a98915f5..d449d2c1f 100644 --- a/backend/src/dispatch/affinity/resolve.rs +++ b/backend/src/dispatch/affinity/resolve.rs @@ -10,7 +10,7 @@ use crate::{ }, upstream::openai::protocol::{ events::TokenUsage, - responses::{completed_response_metadata, CodexResponsesRequest}, + responses::{CodexResponsesRequest, completed_response_metadata}, }, }; @@ -146,12 +146,11 @@ fn build_variant_identity(request: &CodexResponsesRequest) -> Option { if let Some(window_id) = non_empty_str(request.codex_window_id.as_deref()) { parts.push(format!("window:{window_id}")); } - if request.explicit_prompt_cache_key - || non_empty_str(request.client_conversation_id.as_deref()).is_some() + if (request.explicit_prompt_cache_key + || non_empty_str(request.client_conversation_id.as_deref()).is_some()) + && let Some(anchor) = derive_stable_conversation_key(request) { - if let Some(anchor) = derive_stable_conversation_key(request) { - parts.push(format!("anchor:{anchor}")); - } + parts.push(format!("anchor:{anchor}")); } (!parts.is_empty()).then(|| parts.join("\0")) diff --git a/backend/src/dispatch/errors.rs b/backend/src/dispatch/errors.rs index 6be0deb54..5b277fb8f 100644 --- a/backend/src/dispatch/errors.rs +++ b/backend/src/dispatch/errors.rs @@ -1,7 +1,7 @@ //! Shared upstream error classification for dispatch routes. use chrono::{DateTime, Duration, Utc}; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value, json}; use thiserror::Error; use crate::{ diff --git a/backend/src/dispatch/recording.rs b/backend/src/dispatch/recording.rs index 1a013c8c7..136c24c6c 100644 --- a/backend/src/dispatch/recording.rs +++ b/backend/src/dispatch/recording.rs @@ -1,19 +1,19 @@ use std::time::Instant; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value, json}; use crate::{ dispatch::errors::{ - backend_transport_name, insert_dispatch_error_metadata, - insert_upstream_diagnostics_metadata, upstream_error_http_status, DispatchErrorMetadata, + DispatchErrorMetadata, backend_transport_name, insert_dispatch_error_metadata, + insert_upstream_diagnostics_metadata, upstream_error_http_status, }, infra::time::elapsed_millis_i64, telemetry::{ ops::types::OpsErrorLog, recorder::{ - enrich_event_route_metadata, enrich_usage_record_identity, - event_kind as response_event_kind, reasoning_effort_from_request, - record_dispatch_error_event, DispatchErrorLogRecord, Recorder, + DispatchErrorLogRecord, Recorder, enrich_event_route_metadata, + enrich_usage_record_identity, event_kind as response_event_kind, + reasoning_effort_from_request, record_dispatch_error_event, }, usage::types::UsageRecordLevel, }, @@ -28,10 +28,10 @@ use crate::{ use super::{ errors::{ - dispatch_error_metadata, enrich_response_dispatch_error_metadata, ResponseDispatchError, + ResponseDispatchError, dispatch_error_metadata, enrich_response_dispatch_error_metadata, }, stream::{ - live::{latest_response_id, LiveResponseStreamContext}, + live::{LiveResponseStreamContext, latest_response_id}, sse_failure::{status_code_for_stream_failure, stream_failure_metadata}, trace::{ResponseDispatchAttempt, ResponseDispatchTrace}, }, @@ -236,13 +236,13 @@ pub(super) async fn record_prefetched_response_stream_failure_event( event.model = Some(record.model.to_string()); event.status_code = Some(event_status_code); event.latency_ms = Some(elapsed_millis_i64(record.started_at)); - if !record.rate_limit_headers.is_empty() { - if let Some(object) = metadata.as_object_mut() { - object.insert( - "rateLimitHeaders".to_string(), - serde_json::json!(record.rate_limit_headers), - ); - } + if !record.rate_limit_headers.is_empty() + && let Some(object) = metadata.as_object_mut() + { + object.insert( + "rateLimitHeaders".to_string(), + serde_json::json!(record.rate_limit_headers), + ); } event.metadata = metadata; if let Err(error) = record.recorder.record_error(event).await { diff --git a/backend/src/dispatch/recovery/account_failure.rs b/backend/src/dispatch/recovery/account_failure.rs index 36ef11a16..af9464561 100644 --- a/backend/src/dispatch/recovery/account_failure.rs +++ b/backend/src/dispatch/recovery/account_failure.rs @@ -12,9 +12,9 @@ use crate::{ recovery::{ auth::{auth_failure_account_status, is_auth_upstream_error}, cloudflare::{ - cloudflare_challenge_error_message, cloudflare_path_block_error_message, - is_cloudflare_challenge_upstream_error, is_cloudflare_path_block_upstream_error, - CloudflareRecovery, + CloudflareRecovery, cloudflare_challenge_error_message, + cloudflare_path_block_error_message, is_cloudflare_challenge_upstream_error, + is_cloudflare_path_block_upstream_error, }, exhaustion::AccountExhaustionTracker, }, @@ -26,7 +26,7 @@ use crate::{ fleet::{account::AccountStatus, pool::AccountPoolService}, upstream::openai::{ protocol::responses::ResponsesSseFailure, - transport::{is_banned_upstream_error, CodexClientError}, + transport::{CodexClientError, is_banned_upstream_error}, }, }; diff --git a/backend/src/dispatch/recovery/auth.rs b/backend/src/dispatch/recovery/auth.rs index 1a051f0b2..74709e815 100644 --- a/backend/src/dispatch/recovery/auth.rs +++ b/backend/src/dispatch/recovery/auth.rs @@ -2,7 +2,7 @@ use crate::{ fleet::account::AccountStatus, - upstream::openai::transport::{is_banned_auth_signal, CodexClientError}, + upstream::openai::transport::{CodexClientError, is_banned_auth_signal}, }; pub(crate) fn is_auth_upstream_error(error: &CodexClientError) -> bool { diff --git a/backend/src/dispatch/recovery/history.rs b/backend/src/dispatch/recovery/history.rs index 902bcd3ff..208341aa6 100644 --- a/backend/src/dispatch/recovery/history.rs +++ b/backend/src/dispatch/recovery/history.rs @@ -5,9 +5,9 @@ use serde_json::Value; use crate::{ dispatch::affinity::{ + SessionAffinityEntry, SessionAffinityService, service::{MAX_REPLAY_DEPTH, MAX_REPLAY_SESSION_BYTES, MAX_REPLAY_SNAPSHOT_BYTES}, types::ResponseReplaySnapshot, - SessionAffinityEntry, SessionAffinityService, }, upstream::openai::protocol::responses::{CodexResponsesRequest, StreamCommitPolicy}, }; diff --git a/backend/src/dispatch/service.rs b/backend/src/dispatch/service.rs index 231cffc31..b0bd949ee 100644 --- a/backend/src/dispatch/service.rs +++ b/backend/src/dispatch/service.rs @@ -12,7 +12,7 @@ use std::{pin::Pin, sync::Arc, time::Instant}; use bytes::Bytes; use chrono::Utc; use futures::stream::Stream; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use crate::{ dispatch::{ @@ -31,9 +31,9 @@ use crate::{ history::HistoryRecoveryPlan, }, upstream_call::{ - create_response_with_account_retrying_5xx, verify_acquired_quota_if_required, - AccountUpstreamContext, QuotaVerificationContext, QuotaVerificationDecision, - QUOTA_VERIFY_LIMIT_REACHED_MESSAGE, + AccountUpstreamContext, QUOTA_VERIFY_LIMIT_REACHED_MESSAGE, QuotaVerificationContext, + QuotaVerificationDecision, create_response_with_account_retrying_5xx, + verify_acquired_quota_if_required, }, }, fleet::{ @@ -42,16 +42,16 @@ use crate::{ }, models::service::ModelService, telemetry::{ - recorder::{reasoning_effort_from_request, record_response_event, Recorder}, + recorder::{Recorder, reasoning_effort_from_request, record_response_event}, usage::types::ResponseUsageRecord, }, upstream::openai::{ protocol::{ events::TokenUsage, - responses::{response_from_codex_sse, CodexResponsesRequest, CollectedResponse}, + responses::{CodexResponsesRequest, CollectedResponse, response_from_codex_sse}, }, transport::{ - backend_transport_for_response_request, CodexBackendClient, CodexBackendResponse, + CodexBackendClient, CodexBackendResponse, backend_transport_for_response_request, }, }, }; @@ -59,11 +59,11 @@ use crate::{ use super::{ errors::{ResponseDispatchError, ResponseDispatchStreamError}, recording::{ - insert_response_status_metadata, insert_response_trace_metadata, - insert_response_upstream_diagnostics, insert_websocket_pool_decision, - record_response_dispatch_error_event, record_response_upstream_error_event, ResponseDispatchErrorDetails, ResponseDispatchErrorEventRecord, - ResponseUpstreamErrorEventRecord, + ResponseUpstreamErrorEventRecord, insert_response_status_metadata, + insert_response_trace_metadata, insert_response_upstream_diagnostics, + insert_websocket_pool_decision, record_response_dispatch_error_event, + record_response_upstream_error_event, }, stream::{ sse_failure::is_history_recovery_sse_failure, diff --git a/backend/src/dispatch/stream/lifecycle.rs b/backend/src/dispatch/stream/lifecycle.rs index 1cf8836f0..ba56da637 100644 --- a/backend/src/dispatch/stream/lifecycle.rs +++ b/backend/src/dispatch/stream/lifecycle.rs @@ -7,14 +7,14 @@ use crate::{ affinity::prepare_variant_identity, attempts::AccountAttemptLedger, errors::{ - backend_transport_name, is_continuation_busy_error, is_history_recovery_upstream_error, - is_retryable_account_transport_error, upstream_error_body, - upstream_error_set_cookie_headers, ResponseDispatchError, + ResponseDispatchError, backend_transport_name, is_continuation_busy_error, + is_history_recovery_upstream_error, is_retryable_account_transport_error, + upstream_error_body, upstream_error_set_cookie_headers, }, recording::{ - record_prefetched_response_stream_failure_event, record_response_upstream_error_event, ResponseDispatchErrorDetails, ResponseStreamFailureEventRecord, - ResponseUpstreamErrorEventRecord, + ResponseUpstreamErrorEventRecord, record_prefetched_response_stream_failure_event, + record_response_upstream_error_event, }, recovery::{ account_failure::{isolate_rotatable_account_failure, isolate_sse_account_failure}, @@ -23,7 +23,7 @@ use crate::{ }, service::{ResponseDispatchService, ResponseDispatchStream}, stream::{ - live::{spawn_live_response_stream, LiveResponseStreamContext}, + live::{LiveResponseStreamContext, spawn_live_response_stream}, prefetch::prefetch_first_sse_chunk, sse_failure::{ first_sse_failure, is_history_recovery_sse_failure, sse_failure_error_body, @@ -31,9 +31,9 @@ use crate::{ trace::ResponseDispatchTrace, }, upstream_call::{ - create_response_stream_with_account_retrying_5xx, verify_acquired_quota_if_required, - AccountUpstreamContext, QuotaVerificationContext, QuotaVerificationDecision, - QUOTA_VERIFY_LIMIT_REACHED_MESSAGE, + AccountUpstreamContext, QUOTA_VERIFY_LIMIT_REACHED_MESSAGE, QuotaVerificationContext, + QuotaVerificationDecision, create_response_stream_with_account_retrying_5xx, + verify_acquired_quota_if_required, }, }, fleet::pool::AccountAcquireRequest, diff --git a/backend/src/dispatch/stream/live.rs b/backend/src/dispatch/stream/live.rs index c348569e8..e5138feda 100644 --- a/backend/src/dispatch/stream/live.rs +++ b/backend/src/dispatch/stream/live.rs @@ -6,14 +6,14 @@ use std::{ }; use bytes::Bytes; -use futures::{stream::Stream, StreamExt}; +use futures::{StreamExt, stream::Stream}; use serde_json::Value; use tokio::sync::{mpsc, oneshot}; use crate::{ dispatch::{ - affinity::{resolve::record_response_affinity, SessionAffinityService}, - errors::{backend_transport_name, ResponseDispatchStreamError}, + affinity::{SessionAffinityService, resolve::record_response_affinity}, + errors::{ResponseDispatchStreamError, backend_transport_name}, recording::{ insert_first_token_ms, live_response_rate_limit_headers, live_response_turn_state, record_live_response_stream_event, @@ -28,13 +28,13 @@ use crate::{ protocol::{ events::extract_sse_usage, responses::{ + CodexResponsesRequest, CollectedResponse, reconvert_responses_sse_event_tuple_values, response_from_codex_sse, response_sse_event_is_terminal, update_first_response_output_ms, - CodexResponsesRequest, CollectedResponse, }, sse::{ - encode_sse_event, parse_sse_events, response_failed_sse_event_with_id, - sse_body_has_done, sse_frame_end, DONE_SSE_FRAME, + DONE_SSE_FRAME, encode_sse_event, parse_sse_events, + response_failed_sse_event_with_id, sse_body_has_done, sse_frame_end, }, }, transport::{ @@ -47,9 +47,8 @@ use crate::{ use super::{ sse_failure::{ - status_code_for_stream_failure, stream_failure_metadata, stream_failure_source, - synthetic_stream_disconnected_detail, STREAM_DISCONNECTED_CODE, - STREAM_DISCONNECTED_MESSAGE, + STREAM_DISCONNECTED_CODE, STREAM_DISCONNECTED_MESSAGE, status_code_for_stream_failure, + stream_failure_metadata, stream_failure_source, synthetic_stream_disconnected_detail, }, trace::ResponseDispatchAttempt, }; diff --git a/backend/src/dispatch/stream/prefetch.rs b/backend/src/dispatch/stream/prefetch.rs index 9c7b375ff..9ceeb4e56 100644 --- a/backend/src/dispatch/stream/prefetch.rs +++ b/backend/src/dispatch/stream/prefetch.rs @@ -2,7 +2,7 @@ use bytes::Bytes; use futures::StreamExt; use crate::upstream::openai::{ - protocol::sse::{sse_frame_end, SseError}, + protocol::sse::{SseError, sse_frame_end}, transport::CodexBackendSseStream, }; diff --git a/backend/src/dispatch/stream/sse_failure.rs b/backend/src/dispatch/stream/sse_failure.rs index 6167231a5..502d527a7 100644 --- a/backend/src/dispatch/stream/sse_failure.rs +++ b/backend/src/dispatch/stream/sse_failure.rs @@ -6,7 +6,7 @@ use crate::{ upstream::openai::{ protocol::{ events::TokenUsage, - responses::{response_from_codex_sse, CollectedResponse, ResponsesSseFailure}, + responses::{CollectedResponse, ResponsesSseFailure, response_from_codex_sse}, sse::SseError, }, transport::is_banned_auth_signal, diff --git a/backend/src/dispatch/upstream_call.rs b/backend/src/dispatch/upstream_call.rs index c5e670fc1..e3efe6ad1 100644 --- a/backend/src/dispatch/upstream_call.rs +++ b/backend/src/dispatch/upstream_call.rs @@ -3,29 +3,30 @@ use std::{future::Future, time::Instant}; use chrono::Utc; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use crate::{ dispatch::{ affinity::{AccountIdentityService, AccountScopedIdentity}, attempts::AccountAttemptLedger, errors::{ - is_model_unsupported_upstream_error, is_quota_exhausted_upstream_error, - is_rate_limit_upstream_error, is_retryable_account_transport_error, - is_retryable_upstream_5xx_error, rate_limit_cooldown_until, upstream_error_body, - upstream_error_http_status, upstream_error_set_cookie_headers, ResponseDispatchError, + ResponseDispatchError, is_model_unsupported_upstream_error, + is_quota_exhausted_upstream_error, is_rate_limit_upstream_error, + is_retryable_account_transport_error, is_retryable_upstream_5xx_error, + rate_limit_cooldown_until, upstream_error_body, upstream_error_http_status, + upstream_error_set_cookie_headers, }, recording::{ - insert_response_status_metadata, insert_response_trace_metadata, - insert_response_upstream_diagnostics, record_response_dispatch_error_event, - ResponseDispatchErrorEventRecord, + ResponseDispatchErrorEventRecord, insert_response_status_metadata, + insert_response_trace_metadata, insert_response_upstream_diagnostics, + record_response_dispatch_error_event, }, recovery::{ auth::{auth_failure_account_status, is_auth_upstream_error}, cloudflare::{ - cloudflare_challenge_error_message, cloudflare_path_block_error_message, - is_cloudflare_challenge_upstream_error, is_cloudflare_path_block_upstream_error, - CloudflareRecovery, + CloudflareRecovery, cloudflare_challenge_error_message, + cloudflare_path_block_error_message, is_cloudflare_challenge_upstream_error, + is_cloudflare_path_block_upstream_error, }, exhaustion::AccountExhaustionTracker, }, @@ -50,9 +51,8 @@ use crate::{ responses::{CodexCompactRequest, CodexResponsesRequest}, }, transport::{ - is_banned_upstream_error, CodexBackendClient, CodexBackendResponse, - CodexBackendStreamingResponse, CodexClientError, CodexCompactResponse, - CodexRequestContext, + CodexBackendClient, CodexBackendResponse, CodexBackendStreamingResponse, + CodexClientError, CodexCompactResponse, CodexRequestContext, is_banned_upstream_error, }, }, }; diff --git a/backend/src/fleet/import.rs b/backend/src/fleet/import.rs index 44dc82f6e..8f140949d 100644 --- a/backend/src/fleet/import.rs +++ b/backend/src/fleet/import.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; use serde_json::{Map, Value}; use crate::fleet::account::AccountStatus; -use crate::fleet::refresh::{jwt_expiry, JwtExpiry}; +use crate::fleet::refresh::{JwtExpiry, jwt_expiry}; /// 账号导入条目。 #[derive(Debug, Clone)] diff --git a/backend/src/fleet/manage/export.rs b/backend/src/fleet/manage/export.rs index 3c9b49ac2..531c0190e 100644 --- a/backend/src/fleet/manage/export.rs +++ b/backend/src/fleet/manage/export.rs @@ -1,8 +1,8 @@ use secrecy::ExposeSecret; use super::{ - types::{ExportedAccount, ExportedAccounts}, AccountManageError, AccountManageService, + types::{ExportedAccount, ExportedAccounts}, }; use crate::{fleet::store::StoredAccount, infra::time::china_rfc3339_str}; diff --git a/backend/src/fleet/manage/import.rs b/backend/src/fleet/manage/import.rs index c7312811a..a96a519bf 100644 --- a/backend/src/fleet/manage/import.rs +++ b/backend/src/fleet/manage/import.rs @@ -2,12 +2,12 @@ use chrono::Utc; use secrecy::SecretString; use super::{ - types::{ - import_quota_plan_type, import_status_from_usage_error, import_usage_plan_type, - import_usage_string, AccountManageError, ImportSupplementalAccountInfo, - ImportSupplementalNeeds, ImportedAccountState, ImportedAccounts, ResolvedImportTokens, - }, AccountManageService, + types::{ + AccountManageError, ImportSupplementalAccountInfo, ImportSupplementalNeeds, + ImportedAccountState, ImportedAccounts, ResolvedImportTokens, import_quota_plan_type, + import_status_from_usage_error, import_usage_plan_type, import_usage_string, + }, }; impl AccountManageService { @@ -304,15 +304,14 @@ impl AccountManageService { let chatgpt_account_id = crate::fleet::import::normalize_nonempty_str(account_id); let chatgpt_user_id = crate::fleet::import::normalize_nonempty_str(user_id); - if let Some(chatgpt_account_id) = chatgpt_account_id { - if let Some(existing) = self + if let Some(chatgpt_account_id) = chatgpt_account_id + && let Some(existing) = self .store .find_by_chatgpt_identity(chatgpt_account_id, chatgpt_user_id) .await .map_err(|_| AccountManageError::Inspect)? - { - return Ok(existing.id); - } + { + return Ok(existing.id); } Ok(provided_id.unwrap_or_else(|| crate::fleet::import::normalized_account_id(None))) diff --git a/backend/src/fleet/manage/lifecycle.rs b/backend/src/fleet/manage/lifecycle.rs index 6361bfa6f..21003f837 100644 --- a/backend/src/fleet/manage/lifecycle.rs +++ b/backend/src/fleet/manage/lifecycle.rs @@ -1,10 +1,10 @@ use std::{ - collections::{hash_map::DefaultHasher, HashSet}, + collections::{HashSet, hash_map::DefaultHasher}, hash::{Hash, Hasher}, }; use chrono::{DateTime, Duration, Utc}; -use futures::{stream, StreamExt}; +use futures::{StreamExt, stream}; use secrecy::{ExposeSecret, SecretString}; use tokio::time::sleep; use uuid::Uuid; @@ -16,12 +16,12 @@ use crate::{ }; use super::{ - types::{ - parse_account_status, stored_to_admin_metadata, AccountHealthCheck, AccountManageError, - AccountRefreshOutcome, AccountRefreshResult, AccountUpdate, BatchDeleteAccounts, - ManagedAccount, ManualCreateTokens, - }, AccountManageService, + types::{ + AccountHealthCheck, AccountManageError, AccountRefreshOutcome, AccountRefreshResult, + AccountUpdate, BatchDeleteAccounts, ManagedAccount, ManualCreateTokens, + parse_account_status, stored_to_admin_metadata, + }, }; const ADMIN_REFRESH_LEASE_TTL_SECONDS: i64 = 5 * 60; diff --git a/backend/src/fleet/manage/oauth.rs b/backend/src/fleet/manage/oauth.rs index 545f9e1e1..a6f7e8b53 100644 --- a/backend/src/fleet/manage/oauth.rs +++ b/backend/src/fleet/manage/oauth.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, fmt::Write as _, sync::Arc}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Duration, Utc}; use rand::Rng; use reqwest::{Client, StatusCode, Url}; @@ -9,8 +9,8 @@ use sha2::{Digest, Sha256}; use tokio::sync::Mutex; use super::{ - types::{AccountManageError, OAuthAuthorizeResult, OAuthExchangeInput}, AccountManageService, + types::{AccountManageError, OAuthAuthorizeResult, OAuthExchangeInput}, }; const OPENAI_OAUTH_AUTHORIZE_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize"; diff --git a/backend/src/fleet/manage/probe.rs b/backend/src/fleet/manage/probe.rs index b133998f7..2ef3eed3d 100644 --- a/backend/src/fleet/manage/probe.rs +++ b/backend/src/fleet/manage/probe.rs @@ -3,7 +3,7 @@ use std::{convert::Infallible, pin::Pin}; use bytes::Bytes; use futures::{Stream, StreamExt}; use secrecy::ExposeSecret; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tokio::sync::mpsc; use crate::{ @@ -12,15 +12,15 @@ use crate::{ upstream::openai::{ protocol::{ responses::{CodexResponsesRequest, ResponsesSseFailure}, - sse::{encode_sse_event, parse_sse_events, SseEvent}, + sse::{SseEvent, encode_sse_event, parse_sse_events}, }, transport::{ - is_banned_auth_signal, is_banned_upstream_error, CodexClientError, CodexRequestContext, + CodexClientError, CodexRequestContext, is_banned_auth_signal, is_banned_upstream_error, }, }, }; -use super::{types::AccountManageError, AccountManageService}; +use super::{AccountManageService, types::AccountManageError}; pub(super) type AccountTestStream = Pin> + Send>>; @@ -221,14 +221,14 @@ impl AccountManageService { } } - if matches!(status, AccountStatus::Expired | AccountStatus::Banned) { - if let Err(error) = self.store.set_next_refresh_at(account_id, None).await { - tracing::warn!( - account_id, - error = %error, - "failed to clear token refresh schedule after connection test" - ); - } + if matches!(status, AccountStatus::Expired | AccountStatus::Banned) + && let Err(error) = self.store.set_next_refresh_at(account_id, None).await + { + tracing::warn!( + account_id, + error = %error, + "failed to clear token refresh schedule after connection test" + ); } self.sync_account_pool_best_effort(account_id, "connection test") .await; @@ -297,10 +297,10 @@ async fn process_upstream_test_stream( } } - if !buffer.trim().is_empty() { - if let Some(outcome) = process_sse_frame(tx, &buffer).await { - return outcome; - } + if !buffer.trim().is_empty() + && let Some(outcome) = process_sse_frame(tx, &buffer).await + { + return outcome; } AccountTestOutcome::error("Stream ended before response.completed", None) @@ -332,10 +332,10 @@ async fn process_sse_event( }; match value.get("type").and_then(Value::as_str) { Some("response.output_text.delta") => { - if let Some(delta) = value.get("delta").and_then(Value::as_str) { - if !delta.is_empty() { - send_test_event(tx, json!({ "type": "content", "text": delta })).await; - } + if let Some(delta) = value.get("delta").and_then(Value::as_str) + && !delta.is_empty() + { + send_test_event(tx, json!({ "type": "content", "text": delta })).await; } None } diff --git a/backend/src/fleet/manage/quota.rs b/backend/src/fleet/manage/quota.rs index 498078198..8fb302447 100644 --- a/backend/src/fleet/manage/quota.rs +++ b/backend/src/fleet/manage/quota.rs @@ -1,6 +1,6 @@ use secrecy::ExposeSecret; -use super::{types::AccountManageError, AccountManageService}; +use super::{AccountManageService, types::AccountManageError}; impl AccountManageService { async fn usage_cookie_header(&self, account_id: &str) -> Option { diff --git a/backend/src/fleet/pool/mod.rs b/backend/src/fleet/pool/mod.rs index a85d890ff..e48093eb7 100644 --- a/backend/src/fleet/pool/mod.rs +++ b/backend/src/fleet/pool/mod.rs @@ -13,10 +13,10 @@ use indexmap::IndexMap; use thiserror::Error; use crate::fleet::quota::{quota_snapshot_limit_reached, quota_snapshot_reset_at}; -use crate::fleet::refresh::{jwt_expiry, JwtExpiry}; +use crate::fleet::refresh::{JwtExpiry, jwt_expiry}; use crate::fleet::scheduler::{ - candidates::{self, CandidateFilter, CandidateRequest}, AccountScheduler, ScoreWeights, + candidates::{self, CandidateFilter, CandidateRequest}, }; use crate::fleet::store::AccountStore; use crate::fleet::{ @@ -27,7 +27,7 @@ use crate::telemetry::account_usage::store::{ AccountUsageDelta, AccountUsageSnapshot, AccountUsageStore, AccountUsageWindow, }; use crate::upstream::openai::protocol::events::{ - parse_rate_limit_headers, rate_limit_quota, TokenUsage as CodexTokenUsage, + TokenUsage as CodexTokenUsage, parse_rate_limit_headers, rate_limit_quota, }; pub use crate::fleet::scheduler::RotationStrategy; @@ -457,21 +457,20 @@ impl AccountPoolService { "failed to persist refreshed runtime account state" ); } - if refreshed.sync_usage_window { - if let Err(error) = self + if refreshed.sync_usage_window + && let Err(error) = self .usage_store .sync_runtime_window( &refreshed.account.id, account_usage_window(&refreshed.account), ) .await - { - tracing::error!( - account_id = %refreshed.account.id, - error = %error, - "failed to persist refreshed runtime account usage window" - ); - } + { + tracing::error!( + account_id = %refreshed.account.id, + error = %error, + "failed to persist refreshed runtime account usage window" + ); } } } @@ -641,18 +640,17 @@ impl AccountPoolService { } }; - if let Some(reset_at) = reset_at { - if let Err(error) = self + if let Some(reset_at) = reset_at + && let Err(error) = self .usage_store .sync_rate_limit_window(account_id, reset_at, limit_window_seconds) .await - { - tracing::error!( - account_id, - error = %error, - "failed to persist verified quota window" - ); - } + { + tracing::error!( + account_id, + error = %error, + "failed to persist verified quota window" + ); } let in_memory = { diff --git a/backend/src/fleet/pool/state.rs b/backend/src/fleet/pool/state.rs index b0c3c0103..3b3b63bac 100644 --- a/backend/src/fleet/pool/state.rs +++ b/backend/src/fleet/pool/state.rs @@ -451,13 +451,12 @@ impl AccountPool { }); let mut account_ids = Vec::with_capacity(ordered.len()); - if let Some(preferred_account_id) = request.preferred_account_id.as_deref() { - if let Some(index) = ordered + if let Some(preferred_account_id) = request.preferred_account_id.as_deref() + && let Some(index) = ordered .iter() .position(|account| account.id == preferred_account_id) - { - account_ids.push(ordered.remove(index).id); - } + { + account_ids.push(ordered.remove(index).id); } account_ids.extend(ordered.into_iter().map(|account| account.id)); AccountCandidateSnapshotWithStatusRefresh { diff --git a/backend/src/fleet/quota/runtime.rs b/backend/src/fleet/quota/runtime.rs index e28c43c89..c7855db3b 100644 --- a/backend/src/fleet/quota/runtime.rs +++ b/backend/src/fleet/quota/runtime.rs @@ -4,7 +4,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use chrono::Utc; use thiserror::Error; -use tokio::time::{sleep, Instant}; +use tokio::time::{Instant, sleep}; use tracing::warn; use crate::fleet::{ diff --git a/backend/src/fleet/quota/service.rs b/backend/src/fleet/quota/service.rs index 0888ed2d0..85473af56 100644 --- a/backend/src/fleet/quota/service.rs +++ b/backend/src/fleet/quota/service.rs @@ -3,7 +3,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; const MONTH_WINDOW_MINUTES: u64 = 43_200; const MONTH_WINDOW_SECONDS: u64 = 2_592_000; diff --git a/backend/src/fleet/refresh/policy.rs b/backend/src/fleet/refresh/policy.rs index 832f9bea1..8139b7fa4 100644 --- a/backend/src/fleet/refresh/policy.rs +++ b/backend/src/fleet/refresh/policy.rs @@ -7,10 +7,10 @@ use std::{ time::Duration as StdDuration, }; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::{DateTime, Duration, TimeZone, Utc}; use serde_json::{Map, Value}; -use tokio::sync::{watch, Semaphore}; +use tokio::sync::{Semaphore, watch}; use crate::fleet::account::{Account, AccountStatus}; use crate::upstream::openai::token_client::{RefreshFailure, TokenPair, TokenRefresher}; diff --git a/backend/src/fleet/refresh/service.rs b/backend/src/fleet/refresh/service.rs index 4ca0a7927..9c2a32a51 100644 --- a/backend/src/fleet/refresh/service.rs +++ b/backend/src/fleet/refresh/service.rs @@ -5,8 +5,8 @@ use std::{ future::Future, pin::Pin, sync::{ - atomic::{AtomicBool, Ordering}, Arc, Mutex as StdMutex, + atomic::{AtomicBool, Ordering}, }, time::Duration, }; @@ -30,12 +30,12 @@ use crate::upstream::openai::token_client::TokenRefresher; use super::{ lease::{RedisRefreshLeaseStore, RedisRefreshLeaseStoreError}, policy::{ - default_refresh_retry_delays, duration_until, is_permanent_refresh_failure_status, - jittered_refresh_at, jwt_expiration, same_refresh_time, scheduled_at_from_delay, - stable_jittered_duration, token_refresh_status_eligible, RefreshError, RefreshPolicy, - RefreshScheduler, RuntimeRefreshPolicy, TokenTimerSummary, PERMANENT_FAILURE_CONFIRMATION_THRESHOLD, RECOVERY_DELAY_JITTER, RECOVERY_DELAY_SECONDS, - RETRY_DELAY_JITTER, + RETRY_DELAY_JITTER, RefreshError, RefreshPolicy, RefreshScheduler, RuntimeRefreshPolicy, + TokenTimerSummary, default_refresh_retry_delays, duration_until, + is_permanent_refresh_failure_status, jittered_refresh_at, jwt_expiration, + same_refresh_time, scheduled_at_from_delay, stable_jittered_duration, + token_refresh_status_eligible, }, }; diff --git a/backend/src/fleet/scheduler/feedback.rs b/backend/src/fleet/scheduler/feedback.rs index 8ab440c9f..8c002d022 100644 --- a/backend/src/fleet/scheduler/feedback.rs +++ b/backend/src/fleet/scheduler/feedback.rs @@ -6,8 +6,8 @@ //! 对齐 sub2api 的设计,避免选择热路径上的锁与 DB 往返。 use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::RwLock; +use std::sync::atomic::{AtomicU64, Ordering}; /// EWMA 平滑系数(新样本权重)。与 sub2api 一致取 0.2。 const EWMA_ALPHA: f64 = 0.2; @@ -59,11 +59,7 @@ impl AccountFeedback { /// 读取 EWMA 样本;哨兵(NaN)返回 `None`。 fn load_sample(bits: &AtomicU64) -> Option { let value = f64::from_bits(bits.load(Ordering::Relaxed)); - if value.is_nan() { - None - } else { - Some(value) - } + if value.is_nan() { None } else { Some(value) } } /// 无锁 CAS 更新 EWMA:首个样本直接落值,其后按 alpha 混合。 diff --git a/backend/src/fleet/scheduler/strategy/quota_reset.rs b/backend/src/fleet/scheduler/strategy/quota_reset.rs index 02777b670..f79578bd2 100644 --- a/backend/src/fleet/scheduler/strategy/quota_reset.rs +++ b/backend/src/fleet/scheduler/strategy/quota_reset.rs @@ -12,7 +12,7 @@ use std::cmp::Ordering; use crate::fleet::account::Account; use crate::fleet::scheduler::{ - compare_last_used, compare_window_reset, order_by, select_by, SelectionInput, + SelectionInput, compare_last_used, compare_window_reset, order_by, select_by, }; /// QuotaResetPriority 策略选择。 diff --git a/backend/src/fleet/scheduler/strategy/smart.rs b/backend/src/fleet/scheduler/strategy/smart.rs index c43ed77cb..455ef1abc 100644 --- a/backend/src/fleet/scheduler/strategy/smart.rs +++ b/backend/src/fleet/scheduler/strategy/smart.rs @@ -11,7 +11,7 @@ use crate::fleet::account::Account; use crate::fleet::scheduler::feedback::{FeedbackSample, FeedbackStats}; -use crate::fleet::scheduler::{account_window_token_count, SelectionInput}; +use crate::fleet::scheduler::{SelectionInput, account_window_token_count}; /// 配额封顶账号的惩罚分。远大于各因子加权和的理论上限(各因子 ∈ [0,1],权重和通常为 /// 个位数),保证未封顶账号总是优先于封顶账号。 @@ -202,11 +202,11 @@ impl NormalizationContext { max_window_cached = max_window_cached.max(account.window_cached_tokens as f64); max_window_requests = max_window_requests.max(account.window_request_count as f64); - if let Some(ttft) = sample.ttft_ms { - if ttft > 0.0 { - min_ttft_ms = min_ttft_ms.min(ttft); - max_ttft_ms = max_ttft_ms.max(ttft); - } + if let Some(ttft) = sample.ttft_ms + && ttft > 0.0 + { + min_ttft_ms = min_ttft_ms.min(ttft); + max_ttft_ms = max_ttft_ms.max(ttft); } if let Some(reset_at) = account.window_reset_at { diff --git a/backend/src/fleet/scheduler/strategy/sticky.rs b/backend/src/fleet/scheduler/strategy/sticky.rs index e6638c1a3..39b43714c 100644 --- a/backend/src/fleet/scheduler/strategy/sticky.rs +++ b/backend/src/fleet/scheduler/strategy/sticky.rs @@ -6,7 +6,7 @@ use std::cmp::Ordering; use crate::fleet::account::Account; -use crate::fleet::scheduler::{compare_last_used, SelectionInput}; +use crate::fleet::scheduler::{SelectionInput, compare_last_used}; /// 选中最近使用过的账号(`last_used_at` 最大者)。 /// diff --git a/backend/src/fleet/store/mod.rs b/backend/src/fleet/store/mod.rs index 042de4339..1c1f40152 100644 --- a/backend/src/fleet/store/mod.rs +++ b/backend/src/fleet/store/mod.rs @@ -8,7 +8,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder, Row}; use thiserror::Error; use crate::fleet::account::{Account, AccountStatus}; -use crate::infra::json::{page_offset, NumberedPage}; +use crate::infra::json::{NumberedPage, page_offset}; mod queries; mod rows; @@ -249,7 +249,7 @@ pub trait AccountStore: Send + Sync + 'static { /// 更新账号状态。 async fn set_status(&self, account_id: &str, status: AccountStatus) - -> AccountStoreResult; + -> AccountStoreResult; /// 读取账号当前配额 JSON。 async fn get_quota_json(&self, account_id: &str) -> AccountStoreResult>; diff --git a/backend/src/fleet/store/rows.rs b/backend/src/fleet/store/rows.rs index 6d8382235..ea4e963d9 100644 --- a/backend/src/fleet/store/rows.rs +++ b/backend/src/fleet/store/rows.rs @@ -2,7 +2,7 @@ use secrecy::SecretString; use serde_json::Value; -use sqlx::{postgres::PgRow, PgPool, Postgres, QueryBuilder, Row}; +use sqlx::{PgPool, Postgres, QueryBuilder, Row, postgres::PgRow}; use crate::{ fleet::{ @@ -13,9 +13,9 @@ use crate::{ }; use super::{ - queries::{GET_POOL_ACCOUNT_SQL, LIST_POOL_ACCOUNTS_SQL}, AccountQuotaSnapshot, AccountStoreError, PgAccountStore, PgAccountStoreError, PgAccountStoreResult, StoredAccount, StoredAccountMetadata, + queries::{GET_POOL_ACCOUNT_SQL, LIST_POOL_ACCOUNTS_SQL}, }; pub(super) async fn list_pool_accounts( diff --git a/backend/src/infra/database.rs b/backend/src/infra/database.rs index 6d970a3ae..fcf81f006 100644 --- a/backend/src/infra/database.rs +++ b/backend/src/infra/database.rs @@ -3,7 +3,7 @@ use std::time::Duration; use sha2::{Digest, Sha256}; -use sqlx::{postgres::PgPoolOptions, PgPool, Postgres, Row, Transaction}; +use sqlx::{PgPool, Postgres, Row, Transaction, postgres::PgPoolOptions}; const MIGRATIONS: &[Migration] = &[Migration { version: 1, diff --git a/backend/src/infra/identity.rs b/backend/src/infra/identity.rs index 342fb2699..a21fdffa5 100644 --- a/backend/src/infra/identity.rs +++ b/backend/src/infra/identity.rs @@ -1,10 +1,10 @@ //! 身份认证相关原语:管理员密码哈希与 API Key 生成。 use argon2::{ - password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, }; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use hmac::{Hmac, KeyInit, Mac}; use rand::Rng; use sha2::{Digest, Sha256}; diff --git a/backend/src/infra/logging.rs b/backend/src/infra/logging.rs index 9f364cb2c..b62657225 100644 --- a/backend/src/infra/logging.rs +++ b/backend/src/infra/logging.rs @@ -16,10 +16,10 @@ use chrono::{DateTime, Duration, NaiveDate, Utc}; use thiserror::Error; use tracing_appender::non_blocking::{ErrorCounter, NonBlockingBuilder, WorkerGuard}; use tracing_subscriber::{ + EnvFilter, fmt::{self as tracing_fmt, format::Writer, time::FormatTime}, layer::SubscriberExt, util::SubscriberInitExt, - EnvFilter, }; use crate::infra::time::{china_date, china_rfc3339_millis}; @@ -459,14 +459,16 @@ impl DroppedLogMonitor { let (shutdown_tx, shutdown_rx) = mpsc::channel(); let handle = thread::Builder::new() .name("tracing-drop-monitor".to_string()) - .spawn(move || loop { - match shutdown_rx.recv_timeout(DROP_MONITOR_INTERVAL) { - Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => { - report_dropped_logs(&mut counters, &mut io::stderr().lock()); - break; - } - Err(mpsc::RecvTimeoutError::Timeout) => { - report_dropped_logs(&mut counters, &mut io::stderr().lock()); + .spawn(move || { + loop { + match shutdown_rx.recv_timeout(DROP_MONITOR_INTERVAL) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => { + report_dropped_logs(&mut counters, &mut io::stderr().lock()); + break; + } + Err(mpsc::RecvTimeoutError::Timeout) => { + report_dropped_logs(&mut counters, &mut io::stderr().lock()); + } } } })?; diff --git a/backend/src/infra/redis.rs b/backend/src/infra/redis.rs index 43a4e59e1..d034dc408 100644 --- a/backend/src/infra/redis.rs +++ b/backend/src/infra/redis.rs @@ -1,6 +1,6 @@ //! Redis 运行态连接与键前缀。 -use redis::{aio::ConnectionManager, AsyncCommands}; +use redis::{AsyncCommands, aio::ConnectionManager}; /// Redis 运行态连接。 #[derive(Clone)] diff --git a/backend/src/keys/manage.rs b/backend/src/keys/manage.rs index 532d806db..36e3dfd32 100644 --- a/backend/src/keys/manage.rs +++ b/backend/src/keys/manage.rs @@ -5,7 +5,7 @@ use crate::infra::json::NumberedPage; use super::{ store::PgClientKeyStore, types::{ - parse_client_key_status, BatchDeleteClientApiKeys, KeyManageError, ManagedClientApiKey, + BatchDeleteClientApiKeys, KeyManageError, ManagedClientApiKey, parse_client_key_status, }, }; diff --git a/backend/src/keys/service.rs b/backend/src/keys/service.rs index c01db5b7c..1961bc65a 100644 --- a/backend/src/keys/service.rs +++ b/backend/src/keys/service.rs @@ -3,8 +3,8 @@ use std::{ collections::BTreeMap, sync::{ - atomic::{AtomicBool, Ordering}, Arc, Mutex, + atomic::{AtomicBool, Ordering}, }, time::Duration, }; diff --git a/backend/src/keys/store.rs b/backend/src/keys/store.rs index 495f50e41..7880c131c 100644 --- a/backend/src/keys/store.rs +++ b/backend/src/keys/store.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use crate::infra::{ identity::generate_client_api_key, - json::{page_offset, NumberedPage}, + json::{NumberedPage, page_offset}, }; const CLIENT_KEY_SELECT_SQL: &str = r"select diff --git a/backend/src/settings/service.rs b/backend/src/settings/service.rs index 246e5a8ba..4ea6f6dc8 100644 --- a/backend/src/settings/service.rs +++ b/backend/src/settings/service.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeMap, sync::Arc}; use subtle::ConstantTimeEq; -use tokio::sync::{watch, Mutex}; +use tokio::sync::{Mutex, watch}; use crate::infra::identity::{generate_admin_api_key, hash_credential}; diff --git a/backend/src/settings/store.rs b/backend/src/settings/store.rs index a406faa79..59ba253df 100644 --- a/backend/src/settings/store.rs +++ b/backend/src/settings/store.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use chrono::Utc; -use sqlx::{postgres::PgRow, PgPool, Row}; +use sqlx::{PgPool, Row, postgres::PgRow}; use super::types::{ManagementApiKeyStatus, SettingsError, SettingsSnapshot}; diff --git a/backend/src/telemetry/account_usage/store.rs b/backend/src/telemetry/account_usage/store.rs index 03dfc4ee2..a3cee77a1 100644 --- a/backend/src/telemetry/account_usage/store.rs +++ b/backend/src/telemetry/account_usage/store.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use sqlx::{postgres::PgRow, PgPool, Postgres, QueryBuilder, Row}; +use sqlx::{PgPool, Postgres, QueryBuilder, Row, postgres::PgRow}; use thiserror::Error; use crate::infra::format::nonnegative_i64_to_u64; diff --git a/backend/src/telemetry/ops/store.rs b/backend/src/telemetry/ops/store.rs index 5f59d883a..0169f7ffb 100644 --- a/backend/src/telemetry/ops/store.rs +++ b/backend/src/telemetry/ops/store.rs @@ -5,7 +5,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder, Row}; use thiserror::Error; use crate::{ - infra::json::{page_offset, NumberedPage}, + infra::json::{NumberedPage, page_offset}, telemetry::{buckets::store::PgRequestBucketStore, ops::types::OpsErrorLog}, }; diff --git a/backend/src/telemetry/recorder.rs b/backend/src/telemetry/recorder.rs index d9a9b3acf..00c640589 100644 --- a/backend/src/telemetry/recorder.rs +++ b/backend/src/telemetry/recorder.rs @@ -12,8 +12,8 @@ use crate::{ usage::{ store::{PgUsageRecordStore, PgUsageRecordStoreError}, types::{ - metadata_i64, metadata_service_tier, metadata_string as metadata_string_any, - ResponseUsageRecord, UsageRecord, + ResponseUsageRecord, UsageRecord, metadata_i64, metadata_service_tier, + metadata_string as metadata_string_any, }, }, }, @@ -150,13 +150,13 @@ pub(crate) async fn record_response_event(record: ResponseUsageRecord<'_>) { record.reasoning_effort, record.service_tier, ); - if !record.rate_limit_headers.is_empty() { - if let Some(object) = metadata.as_object_mut() { - object.insert( - "rateLimitHeaders".to_string(), - serde_json::json!(record.rate_limit_headers), - ); - } + if !record.rate_limit_headers.is_empty() + && let Some(object) = metadata.as_object_mut() + { + object.insert( + "rateLimitHeaders".to_string(), + serde_json::json!(record.rate_limit_headers), + ); } lift_success_fact_fields(&mut event, &mut metadata); diff --git a/backend/src/telemetry/usage/query.rs b/backend/src/telemetry/usage/query.rs index 203b07097..8199ab1cb 100644 --- a/backend/src/telemetry/usage/query.rs +++ b/backend/src/telemetry/usage/query.rs @@ -11,7 +11,7 @@ use crate::{ telemetry::{ billing, usage::store::{ - push_filter, PgUsageRecordStore, PgUsageRecordStoreResult, UsageRecordFilter, + PgUsageRecordStore, PgUsageRecordStoreResult, UsageRecordFilter, push_filter, }, usage::types::UsageRecord, }, diff --git a/backend/src/telemetry/usage/store.rs b/backend/src/telemetry/usage/store.rs index ab7bcda53..3b4c870f3 100644 --- a/backend/src/telemetry/usage/store.rs +++ b/backend/src/telemetry/usage/store.rs @@ -1,10 +1,10 @@ //! PostgreSQL 成功使用事实存储。 use super::query::{ - count_usage_records, usage_account_usage, usage_breakdown, usage_record_account_email_map, - usage_record_from_row, usage_summary, usage_trend, UsageRecordAccountUsage, - UsageRecordBreakdown, UsageRecordEndpointSource, UsageRecordModelSource, UsageRecordSummary, - UsageRecordTrendPoint, + UsageRecordAccountUsage, UsageRecordBreakdown, UsageRecordEndpointSource, + UsageRecordModelSource, UsageRecordSummary, UsageRecordTrendPoint, count_usage_records, + usage_account_usage, usage_breakdown, usage_record_account_email_map, usage_record_from_row, + usage_summary, usage_trend, }; use std::collections::HashMap; @@ -14,7 +14,7 @@ use sqlx::{PgPool, Postgres, QueryBuilder}; use thiserror::Error; use crate::{ - infra::json::{page_offset, NumberedPage}, + infra::json::{NumberedPage, page_offset}, telemetry::{buckets::store::PgRequestBucketStore, usage::types::UsageRecord}, }; @@ -221,12 +221,8 @@ insert into usage_records ( source: UsageRecordModelSource, ) -> PgUsageRecordStoreResult> { let expression = match source { - UsageRecordModelSource::Requested => { - "coalesce(nullif(requested_model, ''), model)" - } - UsageRecordModelSource::Upstream => { - "coalesce(nullif(upstream_model, ''), model)" - } + UsageRecordModelSource::Requested => "coalesce(nullif(requested_model, ''), model)", + UsageRecordModelSource::Upstream => "coalesce(nullif(upstream_model, ''), model)", UsageRecordModelSource::Mapping => { "coalesce(nullif(requested_model, ''), model) || ' -> ' || coalesce(nullif(upstream_model, ''), model)" } diff --git a/backend/src/update/archive.rs b/backend/src/update/archive.rs index 60ae8d9d1..84d1c1650 100644 --- a/backend/src/update/archive.rs +++ b/backend/src/update/archive.rs @@ -10,7 +10,7 @@ use tar::Archive; use crate::update::types::UpdateError; -use super::service::{internal_error, internal_error_with, SystemUpdateConfig, APP_BINARY_NAME}; +use super::service::{APP_BINARY_NAME, SystemUpdateConfig, internal_error, internal_error_with}; #[derive(Debug)] pub(super) struct ExtractedRelease { @@ -125,22 +125,22 @@ pub(super) fn replace_release_files( }; let binary_backup = backup_path_for(exe_path); - if binary_backup.exists() { - if let Err(error) = fs::remove_file(&binary_backup) { - let mut rollback_errors = Vec::new(); - if web_replaced { - collect_rollback_error( - &mut rollback_errors, - "restore web assets", - restore_dir(web_dist_dir, &web_backup), - ); - } - return Err(update_error_with_rollback( - "Failed to remove old binary backup", - error, - rollback_errors, - )); + if binary_backup.exists() + && let Err(error) = fs::remove_file(&binary_backup) + { + let mut rollback_errors = Vec::new(); + if web_replaced { + collect_rollback_error( + &mut rollback_errors, + "restore web assets", + restore_dir(web_dist_dir, &web_backup), + ); } + return Err(update_error_with_rollback( + "Failed to remove old binary backup", + error, + rollback_errors, + )); } if let Err(error) = move_file(exe_path, &binary_backup) { let mut rollback_errors = Vec::new(); diff --git a/backend/src/update/download.rs b/backend/src/update/download.rs index c718cb0c5..e5bd411ee 100644 --- a/backend/src/update/download.rs +++ b/backend/src/update/download.rs @@ -12,14 +12,15 @@ use sha2::{Digest, Sha256}; use crate::update::types::UpdateError; use super::service::{ - bad_gateway_with, bad_request_with, emit_update_event, format_bytes, internal_error_with, - UpdateLogLevel, + UpdateEventSender, UpdateLogLevel, bad_gateway_with, bad_request_with, format_bytes, + internal_error_with, }; -#[derive(Debug, Clone, Copy)] +#[derive(Clone, Copy)] pub(super) struct DownloadProgress<'a> { pub operation_id: &'a str, pub total_size: u64, + pub events: &'a UpdateEventSender, } pub(super) async fn download_file( @@ -65,7 +66,7 @@ pub(super) async fn download_file( if progress.total_size > 0 && (percent >= next_progress || downloaded >= progress.total_size) { - emit_update_event( + progress.events.emit( UpdateLogLevel::Info, Some(progress.operation_id), Some("download"), diff --git a/backend/src/update/release.rs b/backend/src/update/release.rs index 2cd0e85cc..005d672eb 100644 --- a/backend/src/update/release.rs +++ b/backend/src/update/release.rs @@ -13,13 +13,11 @@ use crate::update::types::UpdateError; use super::{ download::validate_github_api_base, - service::{build_type_label, deployment_mode_label, SystemUpdateConfig, APP_BINARY_NAME}, + service::{APP_BINARY_NAME, SystemUpdateConfig, build_type_label, deployment_mode_label}, }; const UPDATE_CACHE_TTL: Duration = Duration::from_secs(20 * 60); -static RELEASE_CACHE: Mutex> = Mutex::const_new(None); - #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct UpdateInfoData { @@ -45,6 +43,18 @@ struct CachedUpdateInfo { cached_at: Instant, } +pub(super) struct ReleaseCache { + entry: Mutex>, +} + +impl Default for ReleaseCache { + fn default() -> Self { + Self { + entry: Mutex::const_new(None), + } + } +} + #[derive(Debug, Clone, Deserialize)] pub(super) struct GitHubRelease { pub tag_name: String, @@ -64,6 +74,7 @@ pub(super) struct GitHubAsset { } pub(super) async fn check_latest_release( + cache: &ReleaseCache, config: &SystemUpdateConfig, force: bool, ) -> UpdateInfoData { @@ -103,19 +114,17 @@ pub(super) async fn check_latest_release( } let cache_key = config.release_cache_key(); - if !force { - if let Some(info) = cached_release_info(&cache_key).await { - return info; - } + if !force && let Some(info) = cached_release_info(cache, &cache_key).await { + return info; } match fetch_latest_release(&config.github_api_base, repository).await { Ok(release) => { let info = update_info_from_release(config, release); - cache_release_info(cache_key, &info).await; + cache_release_info(cache, cache_key, &info).await; info } - Err(error) => cached_release_info(&cache_key) + Err(error) => cached_release_info(cache, &cache_key) .await .unwrap_or_else(|| UpdateInfoData { current_version: config.version.clone(), @@ -224,24 +233,24 @@ pub(super) fn select_release_archive<'a>( }) } -async fn cached_release_info(cache_key: &str) -> Option { +async fn cached_release_info(cache: &ReleaseCache, cache_key: &str) -> Option { let mut info = { - let cache = RELEASE_CACHE.lock().await; - let cached = cache.as_ref()?; + let entry = cache.entry.lock().await; + let cached = entry.as_ref()?; if cached.key != cache_key || cached.cached_at.elapsed() > UPDATE_CACHE_TTL { return None; } let info = cached.info.clone(); - drop(cache); + drop(entry); info }; info.cached = true; Some(info) } -async fn cache_release_info(cache_key: String, info: &UpdateInfoData) { - let mut cache = RELEASE_CACHE.lock().await; - *cache = Some(CachedUpdateInfo { +async fn cache_release_info(cache: &ReleaseCache, cache_key: String, info: &UpdateInfoData) { + let mut entry = cache.entry.lock().await; + *entry = Some(CachedUpdateInfo { key: cache_key, info: info.clone(), cached_at: Instant::now(), diff --git a/backend/src/update/service.rs b/backend/src/update/service.rs index ec3fdd351..a0188c832 100644 --- a/backend/src/update/service.rs +++ b/backend/src/update/service.rs @@ -4,26 +4,25 @@ use std::{ env, fmt, fs, io, path::{Path, PathBuf}, process::Command, - sync::OnceLock, }; use chrono::Utc; use serde::Serialize; -use tokio::sync::{broadcast, Mutex}; +use tokio::sync::{Mutex, broadcast}; use super::{ archive::{extract_release_archive, replace_release_files, rollback_release_update}, download::{ - download_file, validate_download_url, validate_github_api_base, verify_checksum, - DownloadProgress, + DownloadProgress, download_file, validate_download_url, validate_github_api_base, + verify_checksum, }, release::{ - check_latest_release, fetch_latest_release, normalize_version_tag, select_release_archive, - update_info_from_release, GitHubRelease, UpdateInfoData, + GitHubRelease, ReleaseCache, UpdateInfoData, check_latest_release, fetch_latest_release, + normalize_version_tag, select_release_archive, update_info_from_release, }, state::{ - finish_operation, operation_id, read_state, set_operation_running, OperationLock, - SystemOperationKind, SystemUpdateStatusData, + OperationLock, SystemOperationKind, SystemUpdateStatusData, finish_operation, operation_id, + read_state, set_operation_running, }, types::UpdateError, }; @@ -35,9 +34,6 @@ const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; const RESTART_DELAY_ENV: &str = "CPR_RESTART_DELAY_MS"; const REPLACEMENT_START_DELAY_MS: &str = "1200"; -static SYSTEM_OPERATION_LOCK: Mutex<()> = Mutex::const_new(()); -static UPDATE_EVENT_SENDER: OnceLock> = OnceLock::new(); - #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct VersionData { @@ -96,145 +92,170 @@ impl SystemUpdateEvent { } #[derive(Debug, Clone)] -pub(super) struct SystemUpdateConfig { - pub(super) version: String, - pub(super) git_sha: String, - pub(super) build_time: String, - pub(super) deployment_mode: String, - pub(super) build_type: String, - pub(super) update_channel: String, - pub(super) update_repository: Option, - pub(super) github_api_base: String, - pub(super) executable_path: Option, - pub(super) web_dist_dir: PathBuf, - pub(super) update_state_file: PathBuf, - pub(super) update_lock_file: PathBuf, - pub(super) update_temp_dir: PathBuf, +pub struct SystemUpdateConfig { + pub version: String, + pub git_sha: String, + pub build_time: String, + pub deployment_mode: String, + pub build_type: String, + pub update_channel: String, + pub update_repository: Option, + pub github_api_base: String, + pub executable_path: Option, + pub web_dist_dir: PathBuf, + pub update_state_file: PathBuf, + pub update_lock_file: PathBuf, + pub update_temp_dir: PathBuf, + pub self_restart_enabled: bool, } -pub(crate) async fn version_data() -> VersionData { - let config = SystemUpdateConfig::from_env(); - let update_info = check_latest_release(&config, false).await; - config.version_data(&update_info) +pub struct SystemUpdateService { + config: SystemUpdateConfig, + operation_lock: Mutex<()>, + release_cache: ReleaseCache, + events: UpdateEventSender, } -pub(crate) async fn update_detail(refresh: bool) -> UpdateInfoData { - let config = SystemUpdateConfig::from_env(); - check_latest_release(&config, refresh).await -} - -pub(crate) fn subscribe_update_events() -> broadcast::Receiver { - update_event_sender().subscribe() -} - -pub(crate) async fn perform_update( - target_version: Option, -) -> Result { - let _guard = SYSTEM_OPERATION_LOCK - .try_lock() - .map_err(|_| UpdateError::conflict("System update already running"))?; - let config = SystemUpdateConfig::from_env(); - if let Some(reason) = config.update_support_error() { - emit_terminal_update_event( - UpdateLogLevel::Error, - None, - Some("preflight"), - reason.clone(), - ); - return Err(UpdateError::conflict(reason)); +impl SystemUpdateService { + pub fn new(config: SystemUpdateConfig) -> Self { + Self { + config, + operation_lock: Mutex::const_new(()), + release_cache: ReleaseCache::default(), + events: UpdateEventSender::default(), + } } - let confirmed_target_version = confirmed_update_target(target_version)?; - emit_update_event( - UpdateLogLevel::Info, - None, - Some("release"), - "正在获取最新 Release 信息", - ); - let release = fetch_latest_release( - &config.github_api_base, - config - .update_repository - .as_deref() - .ok_or_else(|| UpdateError::conflict("Update checks require CPR_UPDATE_REPOSITORY"))?, - ) - .await - .map_err(|error| { - emit_terminal_update_event(UpdateLogLevel::Error, None, Some("release"), error.clone()); - UpdateError::bad_gateway(error) - })?; - let info = update_info_from_release(&config, release.clone()); - if info.latest_version != confirmed_target_version { - let message = format!( - "远端最新版本已变更为 v{},请重新检查并确认", - info.latest_version - ); - emit_terminal_update_event( - UpdateLogLevel::Warning, + pub(crate) fn from_env() -> Self { + Self::new(SystemUpdateConfig::from_env()) + } + + pub(crate) async fn version_data(&self) -> VersionData { + let update_info = check_latest_release(&self.release_cache, &self.config, false).await; + self.config.version_data(&update_info) + } + + pub(crate) async fn update_detail(&self, refresh: bool) -> UpdateInfoData { + check_latest_release(&self.release_cache, &self.config, refresh).await + } + + pub(crate) fn subscribe_update_events(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + pub(crate) async fn perform_update( + &self, + target_version: Option, + ) -> Result { + let _guard = self + .operation_lock + .try_lock() + .map_err(|_| UpdateError::conflict("System update already running"))?; + let config = &self.config; + if let Some(reason) = config.update_support_error() { + self.events.emit_terminal( + UpdateLogLevel::Error, + None, + Some("preflight"), + reason.clone(), + ); + return Err(UpdateError::conflict(reason)); + } + let confirmed_target_version = confirmed_update_target(target_version)?; + + self.events.emit( + UpdateLogLevel::Info, None, Some("release"), - message.clone(), + "正在获取最新 Release 信息", ); - return Err(UpdateError::conflict(message)); - } - if !info.has_update { - emit_terminal_update_event( - UpdateLogLevel::Warning, - None, - Some("release"), - "当前版本已是最新", - ); - return Err(UpdateError::conflict("Already up to date")); - } - let target_version = info.latest_version.clone(); - let operation_id = operation_id("update"); - let lock = OperationLock::acquire(&config.update_lock_file)?; - set_operation_running( - &config.update_state_file, - &operation_id, - SystemOperationKind::Update, - Some(&target_version), - )?; + let release = fetch_latest_release( + &config.github_api_base, + config.update_repository.as_deref().ok_or_else(|| { + UpdateError::conflict("Update checks require CPR_UPDATE_REPOSITORY") + })?, + ) + .await + .map_err(|error| { + self.events + .emit_terminal(UpdateLogLevel::Error, None, Some("release"), error.clone()); + UpdateError::bad_gateway(error) + })?; + let info = update_info_from_release(config, release.clone()); + if info.latest_version != confirmed_target_version { + let message = format!( + "远端最新版本已变更为 v{},请重新检查并确认", + info.latest_version + ); + self.events.emit_terminal( + UpdateLogLevel::Warning, + None, + Some("release"), + message.clone(), + ); + return Err(UpdateError::conflict(message)); + } + if !info.has_update { + self.events.emit_terminal( + UpdateLogLevel::Warning, + None, + Some("release"), + "当前版本已是最新", + ); + return Err(UpdateError::conflict("Already up to date")); + } + let target_version = info.latest_version.clone(); + let operation_id = operation_id("update"); + let lock = OperationLock::acquire(&config.update_lock_file)?; + set_operation_running( + &config.update_state_file, + &operation_id, + SystemOperationKind::Update, + Some(&target_version), + )?; - emit_update_event( - UpdateLogLevel::Info, - Some(&operation_id), - Some("prepare"), - format!("准备更新到 v{target_version}"), - ); - let result = perform_release_update(&config, &release, &target_version, &operation_id).await; - if let Err(error) = &result { - emit_terminal_update_event( - UpdateLogLevel::Error, + self.events.emit( + UpdateLogLevel::Info, Some(&operation_id), - Some("failed"), - error.to_string(), + Some("prepare"), + format!("准备更新到 v{target_version}"), ); - } else { - emit_terminal_update_event( - UpdateLogLevel::Success, - Some(&operation_id), - Some("done"), - "更新文件已替换,等待服务重启生效", + let result = self + .perform_release_update(&release, &target_version, &operation_id) + .await; + if let Err(error) = &result { + self.events.emit_terminal( + UpdateLogLevel::Error, + Some(&operation_id), + Some("failed"), + error.to_string(), + ); + } else { + self.events.emit_terminal( + UpdateLogLevel::Success, + Some(&operation_id), + Some("done"), + "更新文件已替换,等待服务重启生效", + ); + } + finish_operation( + &config.update_state_file, + &operation_id, + SystemOperationKind::Update, + result.as_ref().ok().map(|_| target_version.clone()), + result.as_ref().err().map(ToString::to_string), ); - } - finish_operation( - &config.update_state_file, - &operation_id, - SystemOperationKind::Update, - result.as_ref().ok().map(|_| target_version.clone()), - result.as_ref().err().map(ToString::to_string), - ); - drop(lock); - result?; + drop(lock); + result?; - Ok(UpdateStartedData { - operation_id, - deployment_mode: config.deployment_mode, - message: "更新完成,请重启服务。".to_string(), - need_restart: true, - target_version, - }) + Ok(UpdateStartedData { + operation_id, + deployment_mode: config.deployment_mode.clone(), + message: "更新完成,请重启服务。".to_string(), + need_restart: true, + target_version, + }) + } } fn confirmed_update_target(target_version: Option) -> Result { @@ -248,51 +269,52 @@ fn confirmed_update_target(target_version: Option) -> Result Result { - let config = SystemUpdateConfig::from_env(); - read_state(&config.update_state_file) -} - -pub(crate) async fn rollback() -> Result { - let _guard = SYSTEM_OPERATION_LOCK - .try_lock() - .map_err(|_| UpdateError::conflict("系统操作正在执行中"))?; - let config = SystemUpdateConfig::from_env(); - if let Some(reason) = config.update_support_error() { - return Err(UpdateError::conflict(reason)); +impl SystemUpdateService { + pub(crate) fn update_status(&self) -> Result { + read_state(&self.config.update_state_file) } - let operation_id = operation_id("rollback"); - let lock = OperationLock::acquire(&config.update_lock_file)?; - set_operation_running( - &config.update_state_file, - &operation_id, - SystemOperationKind::Rollback, - None, - )?; - let result = rollback_release_update(&config).await; - finish_operation( - &config.update_state_file, - &operation_id, - SystemOperationKind::Rollback, - None, - result.as_ref().err().map(ToString::to_string), - ); - drop(lock); - result?; + pub(crate) async fn rollback(&self) -> Result { + let _guard = self + .operation_lock + .try_lock() + .map_err(|_| UpdateError::conflict("系统操作正在执行中"))?; + let config = &self.config; + if let Some(reason) = config.update_support_error() { + return Err(UpdateError::conflict(reason)); + } - Ok(operation_id) -} + let operation_id = operation_id("rollback"); + let lock = OperationLock::acquire(&config.update_lock_file)?; + set_operation_running( + &config.update_state_file, + &operation_id, + SystemOperationKind::Rollback, + None, + )?; + let result = rollback_release_update(config).await; + finish_operation( + &config.update_state_file, + &operation_id, + SystemOperationKind::Rollback, + None, + result.as_ref().err().map(ToString::to_string), + ); + drop(lock); + result?; -pub(crate) fn restart_plan() -> Result { - if env_string("CPR_ENABLE_SELF_RESTART").as_deref() != Some("true") { - return Err(UpdateError::conflict( - "自重启未启用,请设置 CPR_ENABLE_SELF_RESTART=true", - )); + Ok(operation_id) } - let config = SystemUpdateConfig::from_env(); - schedule_restart(&config) + pub(crate) fn restart_plan(&self) -> Result { + if !self.config.self_restart_enabled { + return Err(UpdateError::conflict( + "自重启未启用,请设置 CPR_ENABLE_SELF_RESTART=true", + )); + } + + schedule_restart(&self.config) + } } pub(crate) struct RestartPlan { @@ -369,6 +391,7 @@ impl SystemUpdateConfig { update_state_file: state_file, update_lock_file, update_temp_dir, + self_restart_enabled: env_string("CPR_ENABLE_SELF_RESTART").as_deref() == Some("true"), } } @@ -443,127 +466,131 @@ pub(super) fn bad_gateway_with( move |error| UpdateError::bad_gateway(format!("{context}: {error}")) } -async fn perform_release_update( - config: &SystemUpdateConfig, - release: &GitHubRelease, - version: &str, - operation_id: &str, -) -> Result<(), UpdateError> { - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("asset"), - "正在选择匹配当前平台的更新包", - ); - let archive = select_release_archive(release, version)?; - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("asset"), - format!( - "已选择更新包 {} ({})", - archive.name, - format_bytes(archive.size) - ), - ); +impl SystemUpdateService { + async fn perform_release_update( + &self, + release: &GitHubRelease, + version: &str, + operation_id: &str, + ) -> Result<(), UpdateError> { + let config = &self.config; + self.events.emit( + UpdateLogLevel::Info, + Some(operation_id), + Some("asset"), + "正在选择匹配当前平台的更新包", + ); + let archive = select_release_archive(release, version)?; + self.events.emit( + UpdateLogLevel::Info, + Some(operation_id), + Some("asset"), + format!( + "已选择更新包 {} ({})", + archive.name, + format_bytes(archive.size) + ), + ); - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("verify"), - "正在校验下载地址", - ); - validate_download_url(&archive.browser_download_url, &config.github_api_base)?; - if archive.size > MAX_DOWNLOAD_SIZE { - return Err(UpdateError::bad_request("Release archive is too large")); + self.events.emit( + UpdateLogLevel::Info, + Some(operation_id), + Some("verify"), + "正在校验下载地址", + ); + validate_download_url(&archive.browser_download_url, &config.github_api_base)?; + if archive.size > MAX_DOWNLOAD_SIZE { + return Err(UpdateError::bad_request("Release archive is too large")); + } + let checksum = release + .assets + .iter() + .find(|asset| asset.name == "checksums.txt") + .ok_or_else(|| UpdateError::bad_gateway("Release checksums.txt is required"))?; + validate_download_url(&checksum.browser_download_url, &config.github_api_base)?; + + self.events.emit( + UpdateLogLevel::Info, + Some(operation_id), + Some("prepare"), + "正在创建临时更新目录", + ); + fs::create_dir_all(&config.update_temp_dir) + .map_err(internal_error_with("Failed to prepare update temp dir"))?; + let temp_dir = fs::canonicalize(&config.update_temp_dir) + .and_then(|dir| tempfile_dir_in(&dir, ".codex-proxy-rs-update-")) + .map_err(internal_error_with("Failed to create update temp dir"))?; + let archive_path = temp_dir.join(&archive.name); + + self.events.emit( + UpdateLogLevel::Info, + Some(operation_id), + Some("download"), + "开始下载更新包", + ); + download_file( + &archive.browser_download_url, + &archive_path, + MAX_DOWNLOAD_SIZE, + Some(DownloadProgress { + operation_id, + total_size: archive.size, + events: &self.events, + }), + ) + .await?; + self.events.emit( + UpdateLogLevel::Success, + Some(operation_id), + Some("download"), + "更新包下载完成", + ); + + self.events.emit( + UpdateLogLevel::Info, + Some(operation_id), + Some("checksum"), + "正在校验 checksum", + ); + verify_checksum(&archive_path, &archive.name, &checksum.browser_download_url).await?; + self.events.emit( + UpdateLogLevel::Success, + Some(operation_id), + Some("checksum"), + "checksum 校验通过", + ); + + self.events.emit( + UpdateLogLevel::Info, + Some(operation_id), + Some("extract"), + "正在解压更新包", + ); + let extracted = extract_release_archive(&archive_path, &temp_dir)?; + self.events.emit( + UpdateLogLevel::Success, + Some(operation_id), + Some("extract"), + "更新包解压完成", + ); + + self.events.emit( + UpdateLogLevel::Info, + Some(operation_id), + Some("replace"), + "正在替换应用文件", + ); + let exe_path = config.executable_path()?; + replace_release_files(&exe_path, &config.web_dist_dir, extracted)?; + self.events.emit( + UpdateLogLevel::Success, + Some(operation_id), + Some("replace"), + "应用文件替换完成", + ); + let _ = fs::remove_dir_all(temp_dir); + Ok(()) } - let checksum = release - .assets - .iter() - .find(|asset| asset.name == "checksums.txt") - .ok_or_else(|| UpdateError::bad_gateway("Release checksums.txt is required"))?; - validate_download_url(&checksum.browser_download_url, &config.github_api_base)?; - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("prepare"), - "正在创建临时更新目录", - ); - fs::create_dir_all(&config.update_temp_dir) - .map_err(internal_error_with("Failed to prepare update temp dir"))?; - let temp_dir = fs::canonicalize(&config.update_temp_dir) - .and_then(|dir| tempfile_dir_in(&dir, ".codex-proxy-rs-update-")) - .map_err(internal_error_with("Failed to create update temp dir"))?; - let archive_path = temp_dir.join(&archive.name); - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("download"), - "开始下载更新包", - ); - download_file( - &archive.browser_download_url, - &archive_path, - MAX_DOWNLOAD_SIZE, - Some(DownloadProgress { - operation_id, - total_size: archive.size, - }), - ) - .await?; - emit_update_event( - UpdateLogLevel::Success, - Some(operation_id), - Some("download"), - "更新包下载完成", - ); - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("checksum"), - "正在校验 checksum", - ); - verify_checksum(&archive_path, &archive.name, &checksum.browser_download_url).await?; - emit_update_event( - UpdateLogLevel::Success, - Some(operation_id), - Some("checksum"), - "checksum 校验通过", - ); - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("extract"), - "正在解压更新包", - ); - let extracted = extract_release_archive(&archive_path, &temp_dir)?; - emit_update_event( - UpdateLogLevel::Success, - Some(operation_id), - Some("extract"), - "更新包解压完成", - ); - - emit_update_event( - UpdateLogLevel::Info, - Some(operation_id), - Some("replace"), - "正在替换应用文件", - ); - let exe_path = config.executable_path()?; - replace_release_files(&exe_path, &config.web_dist_dir, extracted)?; - emit_update_event( - UpdateLogLevel::Success, - Some(operation_id), - Some("replace"), - "应用文件替换完成", - ); - let _ = fs::remove_dir_all(temp_dir); - Ok(()) } fn tempfile_dir_in(parent: &Path, prefix: &str) -> io::Result { @@ -598,90 +625,88 @@ fn env_string(key: &str) -> Option { .filter(|value| !value.is_empty()) } -fn runtime_build_info_override(key: &str) -> Option { - if cfg!(debug_assertions) { - env_string(key) - } else { - None - } -} - fn build_version() -> String { - runtime_build_info_override("CPR_VERSION").unwrap_or_else(|| { - option_env!("CPR_VERSION") - .unwrap_or(env!("CARGO_PKG_VERSION")) - .to_string() - }) + option_env!("CPR_VERSION") + .unwrap_or(env!("CARGO_PKG_VERSION")) + .to_string() } fn build_git_sha() -> String { - runtime_build_info_override("CPR_GIT_SHA") - .unwrap_or_else(|| option_env!("CPR_GIT_SHA").unwrap_or("unknown").to_string()) + option_env!("CPR_GIT_SHA").unwrap_or("unknown").to_string() } fn build_time() -> String { - runtime_build_info_override("CPR_BUILD_TIME").unwrap_or_else(|| { - option_env!("CPR_BUILD_TIME") - .unwrap_or("unknown") - .to_string() - }) + option_env!("CPR_BUILD_TIME") + .unwrap_or("unknown") + .to_string() } fn build_type() -> String { - runtime_build_info_override("CPR_BUILD_TYPE").unwrap_or_else(|| { - option_env!("CPR_BUILD_TYPE") - .unwrap_or("source") - .to_string() - }) + option_env!("CPR_BUILD_TYPE") + .unwrap_or("source") + .to_string() } -fn update_event_sender() -> &'static broadcast::Sender { - UPDATE_EVENT_SENDER.get_or_init(|| { +pub(super) struct UpdateEventSender { + sender: broadcast::Sender, +} + +impl Default for UpdateEventSender { + fn default() -> Self { let (sender, _receiver) = broadcast::channel(256); - sender - }) + Self { sender } + } } -pub(super) fn emit_update_event( - level: UpdateLogLevel, - operation_id: Option<&str>, - step: Option<&str>, - message: impl Into, -) { - emit_update_event_with_terminal(level, operation_id, step, message, false); -} +impl UpdateEventSender { + fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } -fn emit_terminal_update_event( - level: UpdateLogLevel, - operation_id: Option<&str>, - step: Option<&str>, - message: impl Into, -) { - emit_update_event_with_terminal(level, operation_id, step, message, true); -} + pub(super) fn emit( + &self, + level: UpdateLogLevel, + operation_id: Option<&str>, + step: Option<&str>, + message: impl Into, + ) { + self.emit_with_terminal(level, operation_id, step, message, false); + } -fn emit_update_event_with_terminal( - level: UpdateLogLevel, - operation_id: Option<&str>, - step: Option<&str>, - message: impl Into, - terminal: bool, -) { - let now = Utc::now(); - let event = SystemUpdateEvent { - id: format!( - "update-log-{}", - now.timestamp_nanos_opt() - .unwrap_or_else(|| now.timestamp_millis()) - ), - operation_id: operation_id.map(ToString::to_string), - level, - step: step.map(ToString::to_string), - message: message.into(), - terminal, - at: now.to_rfc3339(), - }; - let _ = update_event_sender().send(event); + fn emit_terminal( + &self, + level: UpdateLogLevel, + operation_id: Option<&str>, + step: Option<&str>, + message: impl Into, + ) { + self.emit_with_terminal(level, operation_id, step, message, true); + } + + fn emit_with_terminal( + &self, + level: UpdateLogLevel, + operation_id: Option<&str>, + step: Option<&str>, + message: impl Into, + terminal: bool, + ) { + let now = Utc::now(); + let event = SystemUpdateEvent { + id: format!( + "update-log-{}", + now.timestamp_nanos_opt() + .unwrap_or_else(|| now.timestamp_millis()) + ), + operation_id: operation_id.map(ToString::to_string), + level, + step: step.map(ToString::to_string), + message: message.into(), + terminal, + at: now.to_rfc3339(), + }; + let _ = self.sender.send(event); + } } fn is_false(value: &bool) -> bool { diff --git a/backend/src/update/state.rs b/backend/src/update/state.rs index 0081e7adb..0e775cddc 100644 --- a/backend/src/update/state.rs +++ b/backend/src/update/state.rs @@ -204,10 +204,10 @@ impl OperationLock { impl Drop for OperationLock { fn drop(&mut self) { - if let Err(error) = fs::remove_file(&self.path) { - if error.kind() != io::ErrorKind::NotFound { - eprintln!("failed to remove update lock: {error}"); - } + if let Err(error) = fs::remove_file(&self.path) + && error.kind() != io::ErrorKind::NotFound + { + eprintln!("failed to remove update lock: {error}"); } } } diff --git a/backend/src/upstream/openai/protocol/events.rs b/backend/src/upstream/openai/protocol/events.rs index 3aa90a19c..ad41083b4 100644 --- a/backend/src/upstream/openai/protocol/events.rs +++ b/backend/src/upstream/openai/protocol/events.rs @@ -1,9 +1,9 @@ use std::{collections::BTreeMap, time::Duration}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; -use crate::upstream::openai::protocol::sse::{parse_sse_events, SseError}; +use crate::upstream::openai::protocol::sse::{SseError, parse_sse_events}; /// 从 Codex/OpenAI usage 结构中提取出的标准化 token 用量。 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -356,17 +356,16 @@ pub fn rate_limit_quota( }) .collect::>(); - if let Some(existing_quota) = existing_quota { - if let Some(existing_snapshots) = existing_quota.get("snapshots").and_then(Value::as_array) - { - for snapshot in existing_snapshots { - if !rate_limits - .limits - .keys() - .any(|limit_id| quota_snapshot_matches_limit(snapshot, limit_id)) - { - snapshots.push(snapshot.clone()); - } + if let Some(existing_quota) = existing_quota + && let Some(existing_snapshots) = existing_quota.get("snapshots").and_then(Value::as_array) + { + for snapshot in existing_snapshots { + if !rate_limits + .limits + .keys() + .any(|limit_id| quota_snapshot_matches_limit(snapshot, limit_id)) + { + snapshots.push(snapshot.clone()); } } } diff --git a/backend/src/upstream/openai/protocol/responses.rs b/backend/src/upstream/openai/protocol/responses.rs index c66471bf4..ba946a921 100644 --- a/backend/src/upstream/openai/protocol/responses.rs +++ b/backend/src/upstream/openai/protocol/responses.rs @@ -7,7 +7,7 @@ use crate::{ infra::time::elapsed_millis_i64, upstream::openai::protocol::{ schema::reconvert_tuple_values, - sse::{parse_sse_events, SseError, SseEvent}, + sse::{SseError, SseEvent, parse_sse_events}, }, }; diff --git a/backend/src/upstream/openai/protocol/websocket.rs b/backend/src/upstream/openai/protocol/websocket.rs index d0ee682bb..5412eda28 100644 --- a/backend/src/upstream/openai/protocol/websocket.rs +++ b/backend/src/upstream/openai/protocol/websocket.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use crate::upstream::openai::protocol::responses::{ - http_sse_fallback_allowed, transport_for_request, CodexResponsesRequest, CodexTransport, + CodexResponsesRequest, CodexTransport, http_sse_fallback_allowed, transport_for_request, }; use crate::upstream::openai::protocol::sse::encode_sse_event; diff --git a/backend/src/upstream/openai/transport/client.rs b/backend/src/upstream/openai/transport/client.rs index 0175d7df1..1c9f7ff0a 100644 --- a/backend/src/upstream/openai/transport/client.rs +++ b/backend/src/upstream/openai/transport/client.rs @@ -11,21 +11,21 @@ use async_trait::async_trait; use bytes::Bytes; use futures::{Stream, TryStreamExt}; use reqwest::{ - header::{ - HeaderMap, HeaderName, HeaderValue, ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, - COOKIE, RETRY_AFTER, USER_AGENT, - }, Client, Response as ReqwestResponse, StatusCode, + header::{ + ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, COOKIE, HeaderMap, HeaderName, + HeaderValue, RETRY_AFTER, USER_AGENT, + }, }; -use serde_json::{map::Map, Value}; +use serde_json::{Value, map::Map}; use thiserror::Error; use tokio_tungstenite::tungstenite::handshake::client::generate_key; use crate::upstream::openai::fingerprint::{Fingerprint, RuntimeFingerprint}; use crate::upstream::openai::protocol::events::{extract_sse_usage, retry_after_seconds_from_body}; use crate::upstream::openai::protocol::responses::{ - http_sse_fallback_allowed, transport_for_request, CodexCompactRequest, CodexResponsesRequest, - CodexTransport, + CodexCompactRequest, CodexResponsesRequest, CodexTransport, http_sse_fallback_allowed, + transport_for_request, }; use crate::upstream::openai::protocol::sse::SseError; use crate::upstream::openai::protocol::websocket::{ @@ -33,20 +33,20 @@ use crate::upstream::openai::protocol::websocket::{ }; use super::diagnostics::CodexUpstreamDiagnostics; -use super::endpoints::{endpoint_url, CODEX_RESPONSES_COMPACT_PATH, CODEX_RESPONSES_PATH}; +use super::endpoints::{CODEX_RESPONSES_COMPACT_PATH, CODEX_RESPONSES_PATH, endpoint_url}; use super::headers::{ build_ordered_codex_base_headers, insert_optional_header, insert_ordered_headers, websocket_header_pairs, }; use super::response_meta; -use super::tls::{build_reqwest_client_with_custom_ca, custom_ca_env_cache_key, CustomCaError}; +use super::tls::{CustomCaError, build_reqwest_client_with_custom_ca, custom_ca_env_cache_key}; use super::websocket::{ - execute_response_create_request_stream_with_pool, execute_response_create_request_with_pool, - write_websocket_audit_artifact_from_env, CodexWebSocketConnection, CodexWebSocketExchangeError, - CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketTurnStateUpdate, + CodexWebSocketConnection, CodexWebSocketExchangeError, CodexWebSocketRateLimitHeaderUpdates, + CodexWebSocketTurnStateUpdate, execute_response_create_request_stream_with_pool, + execute_response_create_request_with_pool, write_websocket_audit_artifact_from_env, }; use super::websocket_pool::{ - CodexWebSocketPool, CodexWebSocketPoolKey, WebSocketPoolDecision, DEFAULT_INITIAL_EVENT_TIMEOUT, + CodexWebSocketPool, CodexWebSocketPoolKey, DEFAULT_INITIAL_EVENT_TIMEOUT, WebSocketPoolDecision, }; // --------------------------------------------------------------------------- diff --git a/backend/src/upstream/openai/transport/mod.rs b/backend/src/upstream/openai/transport/mod.rs index b91719a8e..ff9943e6c 100644 --- a/backend/src/upstream/openai/transport/mod.rs +++ b/backend/src/upstream/openai/transport/mod.rs @@ -13,17 +13,17 @@ pub(crate) mod websocket_pump; pub use self::{ client::{ - backend_transport_for_response_request, build_reqwest_client, is_banned_auth_signal, - is_banned_upstream_error, CodexBackendClient, CodexBackendResponse, CodexBackendSseStream, + CodexBackendClient, CodexBackendResponse, CodexBackendSseStream, CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexClientResult, CodexCompactResponse, CodexModelCatalogClient, CodexModelCatalogClientError, CodexModelCatalogRequest, CodexRateLimitHeaderUpdates, CodexRequestContext, - CodexResponseMetadata, CodexTurnStateUpdate, + CodexResponseMetadata, CodexTurnStateUpdate, backend_transport_for_response_request, + build_reqwest_client, is_banned_auth_signal, is_banned_upstream_error, }, diagnostics::CodexUpstreamDiagnostics, endpoints::{ - endpoint_request_path, endpoint_url, usage_endpoint_urls, CODEX_RESPONSES_COMPACT_PATH, - CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, CODEX_USAGE_PATH, WHAM_USAGE_PATH, + CODEX_RESPONSES_COMPACT_PATH, CODEX_RESPONSES_PATH, CODEX_USAGE_API_PATH, CODEX_USAGE_PATH, + WHAM_USAGE_PATH, endpoint_request_path, endpoint_url, usage_endpoint_urls, }, headers::{ build_codex_base_headers, build_codex_headers, build_ordered_codex_base_headers, diff --git a/backend/src/upstream/openai/transport/tls.rs b/backend/src/upstream/openai/transport/tls.rs index 7a4b9a4ab..e9300ac59 100644 --- a/backend/src/upstream/openai/transport/tls.rs +++ b/backend/src/upstream/openai/transport/tls.rs @@ -2,8 +2,8 @@ use std::{env, fs, io, path::PathBuf, sync::Arc}; use rustls::{ClientConfig, RootCertStore}; use rustls_pki_types::{ - pem::{self, PemObject, SectionKind}, CertificateDer, + pem::{self, PemObject, SectionKind}, }; use thiserror::Error; diff --git a/backend/src/upstream/openai/transport/usage.rs b/backend/src/upstream/openai/transport/usage.rs index bf4408092..93745b0ab 100644 --- a/backend/src/upstream/openai/transport/usage.rs +++ b/backend/src/upstream/openai/transport/usage.rs @@ -4,10 +4,11 @@ use serde_json::Value; use crate::upstream::openai::protocol::events::retry_after_seconds_from_body; use super::{ + CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext, + CodexUpstreamDiagnostics, client::{read_capped_error_body, retry_after_seconds, truncate_for_error}, endpoints::usage_endpoint_urls, - response_meta, CodexBackendClient, CodexClientError, CodexClientResult, CodexRequestContext, - CodexUpstreamDiagnostics, + response_meta, }; impl CodexBackendClient { diff --git a/backend/src/upstream/openai/transport/websocket.rs b/backend/src/upstream/openai/transport/websocket.rs index fb24a4ed1..d453d7fff 100644 --- a/backend/src/upstream/openai/transport/websocket.rs +++ b/backend/src/upstream/openai/transport/websocket.rs @@ -15,29 +15,28 @@ use futures::Stream; use thiserror::Error; use tokio::sync::mpsc; use tokio::{sync::Mutex, time::timeout}; -use tokio_tungstenite::{connect_async_tls_with_config, Connector}; +use tokio_tungstenite::{Connector, connect_async_tls_with_config}; use tungstenite::{ - self, - extensions::{compression::deflate::DeflateConfig, ExtensionsConfig}, + self, Message, + extensions::{ExtensionsConfig, compression::deflate::DeflateConfig}, handshake::client::Request as WsRequest, http::Response as WsResponse, protocol::WebSocketConfig, - Message, }; use crate::infra::time::china_filename_timestamp_millis; use crate::upstream::openai::protocol::events::{self, TokenUsage}; use crate::upstream::openai::protocol::responses::{ - response_body_has_first_output, CodexResponsesRequest, PreviousResponseScope, - StreamCommitPolicy, + CodexResponsesRequest, PreviousResponseScope, StreamCommitPolicy, + response_body_has_first_output, }; use crate::upstream::openai::protocol::sse::SseError; use crate::upstream::openai::protocol::websocket::{ + ClassifiedWebSocketError, OpeningAuditHeader, OpeningAuditSnapshot, WebSocketAuditArtifact, classify_websocket_error_frame, is_terminal_websocket_event, retry_after_seconds_from_wrapped_error_headers, websocket_event_to_sse_frame, websocket_event_type, websocket_metadata_headers, websocket_metadata_turn_state, websocket_response_completed_id, websocket_response_create_payload_text, - ClassifiedWebSocketError, OpeningAuditHeader, OpeningAuditSnapshot, WebSocketAuditArtifact, }; use super::websocket_pool::{ @@ -182,16 +181,17 @@ impl fmt::Display for PreviousResponseUnavailableReason { #[path = "websocket_frames.rs"] mod frames; +pub use frames::{ + CodexWebSocketExchange, CodexWebSocketExchangeError, CodexWebSocketRateLimitHeaderUpdates, + CodexWebSocketSseStream, CodexWebSocketStreamingExchange, CodexWebSocketTurnStateUpdate, + CodexWebSocketUpstreamError, execute_response_create_request, +}; use frames::{ - audit_header_value, collect_websocket_response, is_initial_event_timeout, + WebSocketStreamPoolReturn, WebSocketTerminalKind, audit_header_value, + collect_websocket_response, is_initial_event_timeout, prefetch_stream_frames_until_output_or_terminal, reusable_websocket_metadata, reused_stream_prefetch_error, stream_websocket_response, websocket_audit_file_name, - websocket_connection_metadata, WebSocketStreamPoolReturn, WebSocketTerminalKind, -}; -pub use frames::{ - execute_response_create_request, CodexWebSocketExchange, CodexWebSocketExchangeError, - CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketSseStream, CodexWebSocketStreamingExchange, - CodexWebSocketTurnStateUpdate, CodexWebSocketUpstreamError, + websocket_connection_metadata, }; impl CodexWebSocketRequest { @@ -539,13 +539,12 @@ pub(crate) async fn execute_response_create_request_with_pool( WebSocketPoolAcquire::Reused { connection, lease } => { if let WebSocketContinuationRequirement::ConnectionLocal { response_id } = request.continuation() + && connection.continuation.latest_response_id() != Some(response_id.as_str()) { - if connection.continuation.latest_response_id() != Some(response_id.as_str()) { - lease.put(*connection).await; - return Err(continuation_unavailable( - PreviousResponseUnavailableReason::LatestResponseMismatch, - )); - } + lease.put(*connection).await; + return Err(continuation_unavailable( + PreviousResponseUnavailableReason::LatestResponseMismatch, + )); } let result = execute_pooled_response_create_request( request, @@ -659,13 +658,12 @@ pub(crate) async fn execute_response_create_request_stream_with_pool( WebSocketPoolAcquire::Reused { connection, lease } => { if let WebSocketContinuationRequirement::ConnectionLocal { response_id } = request.continuation() + && connection.continuation.latest_response_id() != Some(response_id.as_str()) { - if connection.continuation.latest_response_id() != Some(response_id.as_str()) { - lease.put(*connection).await; - return Err(continuation_unavailable( - PreviousResponseUnavailableReason::LatestResponseMismatch, - )); - } + lease.put(*connection).await; + return Err(continuation_unavailable( + PreviousResponseUnavailableReason::LatestResponseMismatch, + )); } let result = execute_pooled_response_create_request_stream(request, pool, lease, *connection) diff --git a/backend/src/upstream/openai/transport/websocket_pool.rs b/backend/src/upstream/openai/transport/websocket_pool.rs index 245cf27d7..32e010041 100644 --- a/backend/src/upstream/openai/transport/websocket_pool.rs +++ b/backend/src/upstream/openai/transport/websocket_pool.rs @@ -2,7 +2,7 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tokio::{sync::Mutex, time::Instant}; use uuid::Uuid; @@ -221,12 +221,10 @@ impl CodexWebSocketPool { ); if owns_reservation && (expired || state.shutting_down || !self.config.enabled) { state.slots.remove(key); - } else if owns_reservation { - if let Some(connection) = connection.take() { - state - .slots - .insert(key.clone(), WebSocketPoolSlot::Idle(Box::new(connection))); - } + } else if owns_reservation && let Some(connection) = connection.take() { + state + .slots + .insert(key.clone(), WebSocketPoolSlot::Idle(Box::new(connection))); } } if let Some(connection) = connection { diff --git a/backend/src/upstream/openai/transport/websocket_pump.rs b/backend/src/upstream/openai/transport/websocket_pump.rs index d060e0167..a6a2a2a81 100644 --- a/backend/src/upstream/openai/transport/websocket_pump.rs +++ b/backend/src/upstream/openai/transport/websocket_pump.rs @@ -13,8 +13,8 @@ //! - 接收:`next` 从 message channel 取出 pump 转发的入站帧(`Ping`/`Pong` 已被 pump 吞掉)。 use std::sync::{ - atomic::{AtomicBool, Ordering}, Arc, + atomic::{AtomicBool, Ordering}, }; use std::time::Duration; @@ -25,7 +25,7 @@ use tokio::{ task::JoinHandle, time::{Instant, MissedTickBehavior}, }; -use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, tungstenite::Message}; /// 底层 tungstenite WebSocket 流。 pub(crate) type RawWsStream = WebSocketStream>; @@ -208,11 +208,10 @@ async fn pump_loop( } _ = tick(&mut ticker) => { // 先检查 liveness:长时间无入站活动则判定失活退出。 - if let Some(timeout) = liveness_timeout { - if last_activity.elapsed() >= timeout { + if let Some(timeout) = liveness_timeout + && last_activity.elapsed() >= timeout { break; } - } // 再按需主动 ping 保活(穿透 NAT/中间盒空闲计时器)。 if ping_interval.is_some() && inner.send(Message::Ping(Vec::new().into())).await.is_err() diff --git a/backend/tests/api/admin/accounts_routes/import_routes.rs b/backend/tests/api/admin/accounts_routes/import_routes.rs index 2503c5dd6..fb88b372d 100644 --- a/backend/tests/api/admin/accounts_routes/import_routes.rs +++ b/backend/tests/api/admin/accounts_routes/import_routes.rs @@ -1,8 +1,8 @@ use super::*; use crate::support::jwt::unsigned_jwt; use wiremock::{ - matchers::{method, path}, Mock, MockServer, ResponseTemplate, + matchers::{method, path}, }; #[tokio::test] @@ -527,8 +527,8 @@ async fn admin_accounts_import_should_complete_chatgpt_account_id_from_refresh_t } #[tokio::test] -async fn admin_accounts_import_from_refresh_token_should_not_store_consumed_refresh_token_when_not_rotated( -) { +async fn admin_accounts_import_from_refresh_token_should_not_store_consumed_refresh_token_when_not_rotated() + { let server = MockServer::start().await; let access_token = test_jwt( "rt-import-no-rotation-account", diff --git a/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs b/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs index 7a45dd945..e543174d8 100644 --- a/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs +++ b/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs @@ -114,15 +114,17 @@ async fn admin_accounts_lifecycle_should_update_and_delete_accounts() { assert_eq!(deleted.status(), StatusCode::OK); assert_eq!(response_json(deleted).await["data"]["deleted"], 1); assert_account_related_rows_deleted(&pool, "acct_lifecycle").await; - assert!(session_affinity - .get( - "resp_lifecycle", - chrono::Utc::now(), - chrono::Duration::hours(1), - ) - .await - .unwrap() - .is_none()); + assert!( + session_affinity + .get( + "resp_lifecycle", + chrono::Utc::now(), + chrono::Duration::hours(1), + ) + .await + .unwrap() + .is_none() + ); } async fn seed_account_related_rows(pool: &PgPool, account_id: &str) { @@ -930,8 +932,8 @@ async fn admin_account_manual_create_should_reject_missing_invalid_expired_or_un } #[tokio::test] -async fn admin_account_manual_create_from_refresh_token_should_not_store_consumed_refresh_token_when_not_rotated( -) { +async fn admin_account_manual_create_from_refresh_token_should_not_store_consumed_refresh_token_when_not_rotated() + { let server = wiremock::MockServer::start().await; let access_token = test_jwt( "rt-create-account", diff --git a/backend/tests/api/admin/accounts_routes/list.rs b/backend/tests/api/admin/accounts_routes/list.rs index 0655eaa73..e58a25f54 100644 --- a/backend/tests/api/admin/accounts_routes/list.rs +++ b/backend/tests/api/admin/accounts_routes/list.rs @@ -82,15 +82,17 @@ async fn admin_accounts_list_should_derive_refreshing_display_status_from_active let now = chrono::Utc::now(); let refresh_leases = codex_proxy_rs::fleet::refresh::RedisRefreshLeaseStore::new(state.redis.clone()); - assert!(refresh_leases - .try_acquire( - "acct_refreshing", - "test-owner", - now + chrono::Duration::hours(1), - now, - ) - .await - .unwrap()); + assert!( + refresh_leases + .try_acquire( + "acct_refreshing", + "test-owner", + now + chrono::Duration::hours(1), + now, + ) + .await + .unwrap() + ); let response = app .oneshot( @@ -131,15 +133,17 @@ async fn admin_accounts_list_should_ignore_refresh_lease_for_expired_account() { let now = chrono::Utc::now(); let refresh_leases = codex_proxy_rs::fleet::refresh::RedisRefreshLeaseStore::new(state.redis.clone()); - assert!(refresh_leases - .try_acquire( - "acct_expired", - "stale-owner", - now + chrono::Duration::hours(1), - now, - ) - .await - .unwrap()); + assert!( + refresh_leases + .try_acquire( + "acct_expired", + "stale-owner", + now + chrono::Duration::hours(1), + now, + ) + .await + .unwrap() + ); let response = app .oneshot( @@ -349,9 +353,11 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() { item["quota"]["windows"][2]["localUsage"]["totalTokensDisplay"], "124K" ); - assert!(item["quota"]["windows"][2]["windowUsedDisplay"] - .as_str() - .is_some_and(|value| value.contains(" / 7.0d"))); + assert!( + item["quota"]["windows"][2]["windowUsedDisplay"] + .as_str() + .is_some_and(|value| value.contains(" / 7.0d")) + ); assert_eq!(item["usage"]["createdTokens"], 972_900); assert_eq!(item["usage"]["readTokens"], 49_100); assert_eq!(item["usage"]["models"].as_array().unwrap().len(), 4); diff --git a/backend/tests/api/admin/accounts_routes/mod.rs b/backend/tests/api/admin/accounts_routes/mod.rs index 5922567b7..be95ff3ba 100644 --- a/backend/tests/api/admin/accounts_routes/mod.rs +++ b/backend/tests/api/admin/accounts_routes/mod.rs @@ -15,7 +15,7 @@ use codex_proxy_rs::{ infra::redis::RedisConnection, }; use secrecy::{ExposeSecret, SecretString}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sqlx::PgPool; use tower::util::ServiceExt; diff --git a/backend/tests/api/admin/accounts_routes/oauth_routes.rs b/backend/tests/api/admin/accounts_routes/oauth_routes.rs index 498070077..5c3eaea59 100644 --- a/backend/tests/api/admin/accounts_routes/oauth_routes.rs +++ b/backend/tests/api/admin/accounts_routes/oauth_routes.rs @@ -1,7 +1,7 @@ use super::*; use wiremock::{ - matchers::{body_string_contains, method, path}, Mock, MockServer, ResponseTemplate, + matchers::{body_string_contains, method, path}, }; #[tokio::test] @@ -41,9 +41,11 @@ async fn admin_account_oauth_authorize_should_return_pkce_auth_url() { Some("S256") ); assert!(params.get("state").is_some_and(|value| !value.is_empty())); - assert!(params - .get("code_challenge") - .is_some_and(|value| !value.is_empty())); + assert!( + params + .get("code_challenge") + .is_some_and(|value| !value.is_empty()) + ); } #[tokio::test] diff --git a/backend/tests/api/admin/accounts_routes/probe_routes.rs b/backend/tests/api/admin/accounts_routes/probe_routes.rs index 32ec7fc45..f230bfcab 100644 --- a/backend/tests/api/admin/accounts_routes/probe_routes.rs +++ b/backend/tests/api/admin/accounts_routes/probe_routes.rs @@ -8,8 +8,8 @@ use codex_proxy_rs::{ upstream::openai::protocol::sse::{encode_sse_event, parse_sse_events}, }; use wiremock::{ - matchers::{header, method, path}, Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, }; async fn seed_test_account(pool: &PgPool) { diff --git a/backend/tests/api/admin/accounts_routes/quota_routes.rs b/backend/tests/api/admin/accounts_routes/quota_routes.rs index c6d6b1ee0..23b200966 100644 --- a/backend/tests/api/admin/accounts_routes/quota_routes.rs +++ b/backend/tests/api/admin/accounts_routes/quota_routes.rs @@ -1,7 +1,7 @@ use super::*; use wiremock::{ - matchers::{method, path}, Mock, MockServer, ResponseTemplate, + matchers::{method, path}, }; #[tokio::test] diff --git a/backend/tests/api/admin/auth_routes/session.rs b/backend/tests/api/admin/auth_routes/session.rs index 83a20a214..cf43d1a97 100644 --- a/backend/tests/api/admin/auth_routes/session.rs +++ b/backend/tests/api/admin/auth_routes/session.rs @@ -94,11 +94,13 @@ async fn admin_logout_should_revoke_session_and_clear_cookie() { .await .unwrap(); assert_eq!(logout.status(), StatusCode::OK); - assert!(logout - .headers() - .get("set-cookie") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.contains("Max-Age=0"))); + assert!( + logout + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("Max-Age=0")) + ); let status = app .oneshot( diff --git a/backend/tests/api/admin/dashboard_routes.rs b/backend/tests/api/admin/dashboard_routes.rs index 1ab06f19b..e047c062b 100644 --- a/backend/tests/api/admin/dashboard_routes.rs +++ b/backend/tests/api/admin/dashboard_routes.rs @@ -10,7 +10,7 @@ use codex_proxy_rs::{ telemetry::{usage::store::PgUsageRecordStore, usage::types::UsageRecord}, upstream::openai::fingerprint::Fingerprint, }; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sqlx::PgPool; use tower::util::ServiceExt; @@ -89,8 +89,8 @@ async fn dashboard_trend_should_report_database_failure() { } #[tokio::test] -async fn dashboard_summary_should_calculate_today_traffic_and_average_first_token_latency_from_time_buckets( -) { +async fn dashboard_summary_should_calculate_today_traffic_and_average_first_token_latency_from_time_buckets() + { let (app, store, pool, _dir) = dashboard_test_app( "dashboard-traffic-latency", crate::support::fingerprint::test_fingerprint(), @@ -138,11 +138,13 @@ async fn dashboard_summary_should_calculate_today_traffic_and_average_first_toke body["data"]["cards"]["cache"]["averageFirstTokenLatencyMs"], "300 ms" ); - assert!(body["data"]["cards"]["cache"] - .as_object() - .unwrap() - .get("averageLatencyMs") - .is_none()); + assert!( + body["data"]["cards"]["cache"] + .as_object() + .unwrap() + .get("averageLatencyMs") + .is_none() + ); } #[tokio::test] diff --git a/backend/tests/api/admin/keys_routes/authorization.rs b/backend/tests/api/admin/keys_routes/authorization.rs index 8879c67bc..5c637fdec 100644 --- a/backend/tests/api/admin/keys_routes/authorization.rs +++ b/backend/tests/api/admin/keys_routes/authorization.rs @@ -34,9 +34,11 @@ async fn admin_client_keys_route_should_create_list_and_authorize_v1_requests() .unwrap(); assert_eq!(create_response.status(), StatusCode::OK); let create_body = response_json(create_response).await; - assert!(create_body["data"]["key"] - .as_str() - .is_some_and(|p| p.starts_with("sk_"))); + assert!( + create_body["data"]["key"] + .as_str() + .is_some_and(|p| p.starts_with("sk_")) + ); let key = create_body["data"]["key"].as_str().unwrap().to_string(); diff --git a/backend/tests/api/admin/response.rs b/backend/tests/api/admin/response.rs index e483f185f..3c5471467 100644 --- a/backend/tests/api/admin/response.rs +++ b/backend/tests/api/admin/response.rs @@ -7,7 +7,7 @@ use codex_proxy_rs::api::admin::response::{ AdminEnvelope, AdminError, AdminPageEnvelope, AdminResponse, }; use codex_proxy_rs::infra::json::NumberedPage; -use serde_json::{json, Value}; +use serde_json::{Value, json}; #[test] fn admin_envelope_should_not_duplicate_request_id_in_body() { diff --git a/backend/tests/api/admin/settings_routes/routes.rs b/backend/tests/api/admin/settings_routes/routes.rs index 9aec99a23..beeb1073e 100644 --- a/backend/tests/api/admin/settings_routes/routes.rs +++ b/backend/tests/api/admin/settings_routes/routes.rs @@ -12,7 +12,7 @@ use codex_proxy_rs::{ api::AppState, bootstrap::{ config::AppConfig, - services::{apply_settings_to_config, settings_snapshot_from_config, Services}, + services::{Services, apply_settings_to_config, settings_snapshot_from_config}, tasks::coordinator::TaskCoordinator, }, fleet::pool::AccountAcquireRequest, diff --git a/backend/tests/api/admin/system_routes/mod.rs b/backend/tests/api/admin/system_routes/mod.rs index 99649e695..52d9acb45 100644 --- a/backend/tests/api/admin/system_routes/mod.rs +++ b/backend/tests/api/admin/system_routes/mod.rs @@ -1,24 +1,28 @@ use std::{ io::Write, - path::Path, + path::{Path, PathBuf}, sync::Arc, time::{Duration, SystemTime}, }; use axum::{ - body::{to_bytes, Body}, - http::{header::CONTENT_TYPE, Request, StatusCode}, + body::{Body, to_bytes}, + http::{Request, StatusCode, header::CONTENT_TYPE}, }; -use codex_proxy_rs::{api::AppState, bootstrap::services::Services}; -use flate2::{write::GzEncoder, Compression}; -use serde_json::{json, Value}; +use codex_proxy_rs::{ + api::{AppState, router::ProcessControl}, + bootstrap::services::Services, + update::service::{SystemUpdateConfig, SystemUpdateService}, +}; +use flate2::{Compression, write::GzEncoder}; +use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use tar::{Builder, EntryType, Header}; -use tokio::sync::Mutex; +use tokio::sync::broadcast; use tower::util::ServiceExt; use wiremock::{ - matchers::{method, path}, Mock, MockServer, ResponseTemplate, + matchers::{method, path}, }; use crate::support::{ @@ -28,20 +32,47 @@ use crate::support::{ http::response_json, }; -static SYSTEM_ENV_LOCK: Mutex<()> = Mutex::const_new(()); const VERSION_OLD: &str = "0.1.0"; const VERSION_NEW: &str = "0.2.0"; +struct TestProcessControl { + shutdown: broadcast::Sender<()>, +} + +impl TestProcessControl { + fn new() -> Self { + let (shutdown, _receiver) = broadcast::channel(16); + Self { shutdown } + } + + fn signal_shutdown(&self) { + let _ = self.shutdown.send(()); + } +} + +impl ProcessControl for TestProcessControl { + fn request_shutdown(&self) { + self.signal_shutdown(); + } + + fn request_restart(&self, _executable_path: PathBuf) { + self.signal_shutdown(); + } + + fn subscribe_shutdown(&self) -> broadcast::Receiver<()> { + self.shutdown.subscribe() + } +} + #[tokio::test] async fn version_should_return_backend_build_metadata() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-version-route"; let github = MockServer::start().await; mount_latest_release(&github, repository, "0.2.0").await; - set_system_update_env(repository, &github.uri()); - std::env::set_var("CPR_GIT_SHA", "version-test-sha"); - std::env::set_var("CPR_BUILD_TIME", "2026-07-01T00:00:00Z"); - let (app, _dir) = admin_system_test_app("system-version").await; + let mut update_config = system_update_config(repository, &github.uri()); + update_config.git_sha = "version-test-sha".to_string(); + update_config.build_time = "2026-07-01T00:00:00Z".to_string(); + let (app, _dir) = admin_system_test_app("system-version", update_config).await; let response = app .oneshot( @@ -73,16 +104,14 @@ async fn version_should_return_backend_build_metadata() { #[tokio::test] async fn update_detail_should_use_cached_release_when_not_refreshed() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-cache-route"; - let success_server = MockServer::start().await; - mount_latest_release(&success_server, repository, "0.2.0").await; - set_system_update_env(repository, &success_server.uri()); - let (app, _dir) = admin_system_test_app("system-cache").await; + let github = MockServer::start().await; + mount_latest_release(&github, repository, "0.2.0").await; + let update_config = system_update_config(repository, &github.uri()); + let (app, _dir) = admin_system_test_app("system-cache", update_config).await; let initial = get_update_detail(&app, true, "req_system_cache_initial").await; - let failing_server = MockServer::start().await; - set_system_update_env(repository, &failing_server.uri()); + github.reset().await; let cached = get_update_detail(&app, false, "req_system_cache_cached").await; assert!( @@ -94,21 +123,19 @@ async fn update_detail_should_use_cached_release_when_not_refreshed() { #[tokio::test] async fn update_detail_should_fallback_to_cache_when_refresh_fetch_fails() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-cache-forced-route"; - let success_server = MockServer::start().await; - mount_latest_release(&success_server, repository, "0.3.0").await; - set_system_update_env(repository, &success_server.uri()); - let (app, _dir) = admin_system_test_app("system-cache-forced").await; + let github = MockServer::start().await; + mount_latest_release(&github, repository, "0.3.0").await; + let update_config = system_update_config(repository, &github.uri()); + let (app, _dir) = admin_system_test_app("system-cache-forced", update_config).await; let initial = get_update_detail(&app, true, "req_system_cache_forced_initial").await; - let failing_server = MockServer::start().await; + github.reset().await; Mock::given(method("GET")) .and(path(format!("/repos/{repository}/releases/latest"))) .respond_with(ResponseTemplate::new(500)) - .mount(&failing_server) + .mount(&github) .await; - set_system_update_env(repository, &failing_server.uri()); let fallback = get_update_detail(&app, true, "req_system_cache_forced_fallback").await; assert!( @@ -121,13 +148,12 @@ async fn update_detail_should_fallback_to_cache_when_refresh_fetch_fails() { #[tokio::test] async fn update_detail_should_report_source_build_as_unsupported() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-source-route"; let github = MockServer::start().await; mount_latest_release(&github, repository, "0.2.0").await; - set_system_update_env(repository, &github.uri()); - std::env::set_var("CPR_BUILD_TYPE", "source"); - let (app, _dir) = admin_system_test_app("system-source-build").await; + let mut update_config = system_update_config(repository, &github.uri()); + update_config.build_type = "source".to_string(); + let (app, _dir) = admin_system_test_app("system-source-build", update_config).await; let info = get_update_detail(&app, true, "req_system_source_build").await; @@ -144,12 +170,11 @@ async fn update_detail_should_report_source_build_as_unsupported() { #[tokio::test] async fn update_detail_should_reject_untrusted_github_api_base() { - let _guard = SYSTEM_ENV_LOCK.lock().await; - set_system_update_env( + let update_config = system_update_config( "zyycn/codex-proxy-rs-untrusted-api-base-route", "https://mirror.example", ); - let (app, _dir) = admin_system_test_app("system-untrusted-api-base").await; + let (app, _dir) = admin_system_test_app("system-untrusted-api-base", update_config).await; let info = get_update_detail(&app, true, "req_system_untrusted_api_base").await; @@ -162,10 +187,12 @@ async fn update_detail_should_reject_untrusted_github_api_base() { #[tokio::test] async fn restart_should_request_process_restart_inside_docker() { - let _guard = SYSTEM_ENV_LOCK.lock().await; - set_system_update_env("zyycn/codex-proxy-rs-restart-route", "http://127.0.0.1:9"); - std::env::set_var("CPR_ENABLE_SELF_RESTART", "true"); - let (app, _dir) = admin_system_test_app("system-restart").await; + let mut update_config = + system_update_config("zyycn/codex-proxy-rs-restart-route", "http://127.0.0.1:9"); + update_config.self_restart_enabled = true; + let (app, _dir, process_control) = + admin_system_test_app_with_process_control("system-restart", update_config).await; + let mut shutdown = process_control.subscribe_shutdown(); let response = app .oneshot( @@ -180,11 +207,7 @@ async fn restart_should_request_process_restart_inside_docker() { .await .unwrap(); let body = response_json(response).await; - let shutdown = tokio::time::timeout( - Duration::from_secs(2), - codex_proxy_rs::bootstrap::shutdown::shutdown_signal(), - ) - .await; + let shutdown = tokio::time::timeout(Duration::from_secs(2), shutdown.recv()).await; assert!( body["data"]["operationId"] @@ -197,19 +220,20 @@ async fn restart_should_request_process_restart_inside_docker() { #[tokio::test] async fn restart_should_spawn_replacement_before_shutdown_outside_docker() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let replacement = Path::new("/bin/true"); if !replacement.exists() { return; } - set_system_update_env( + let mut update_config = system_update_config( "zyycn/codex-proxy-rs-binary-restart-route", "http://127.0.0.1:9", ); - std::env::set_var("CPR_DEPLOYMENT_MODE", "binary"); - std::env::set_var("CPR_ENABLE_SELF_RESTART", "true"); - std::env::set_var("CPR_UPDATE_EXE_PATH", replacement); - let (app, _dir) = admin_system_test_app("system-binary-restart").await; + update_config.deployment_mode = "binary".to_string(); + update_config.self_restart_enabled = true; + update_config.executable_path = Some(replacement.to_path_buf()); + let (app, _dir, process_control) = + admin_system_test_app_with_process_control("system-binary-restart", update_config).await; + let mut shutdown = process_control.subscribe_shutdown(); let response = app .oneshot( @@ -224,29 +248,25 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() { .await .unwrap(); let body = response_json(response).await; - let shutdown = tokio::time::timeout( - Duration::from_secs(2), - codex_proxy_rs::bootstrap::shutdown::shutdown_signal(), - ) - .await; + let shutdown = tokio::time::timeout(Duration::from_secs(2), shutdown.recv()).await; assert!(body["data"]["message"] == "已安排自重启" && shutdown.is_ok()); } #[tokio::test] async fn restart_should_not_shutdown_when_replacement_spawn_fails() { - let _guard = SYSTEM_ENV_LOCK.lock().await; - set_system_update_env( + let mut update_config = system_update_config( "zyycn/codex-proxy-rs-restart-spawn-failure-route", "http://127.0.0.1:9", ); - std::env::set_var("CPR_DEPLOYMENT_MODE", "binary"); - std::env::set_var("CPR_ENABLE_SELF_RESTART", "true"); - std::env::set_var( - "CPR_UPDATE_EXE_PATH", - "/tmp/codex-proxy-rs-missing-restart-binary", - ); - let (app, _dir) = admin_system_test_app("system-restart-spawn-failure").await; + update_config.deployment_mode = "binary".to_string(); + update_config.self_restart_enabled = true; + update_config.executable_path = + Some(PathBuf::from("/tmp/codex-proxy-rs-missing-restart-binary")); + let (app, _dir, process_control) = + admin_system_test_app_with_process_control("system-restart-spawn-failure", update_config) + .await; + let mut shutdown = process_control.subscribe_shutdown(); let response = app .oneshot( @@ -262,11 +282,7 @@ async fn restart_should_not_shutdown_when_replacement_spawn_fails() { .unwrap(); let status = response.status(); let body = response_json(response).await; - let shutdown = tokio::time::timeout( - Duration::from_millis(100), - codex_proxy_rs::bootstrap::shutdown::shutdown_signal(), - ) - .await; + let shutdown = tokio::time::timeout(Duration::from_millis(100), shutdown.recv()).await; let message = body["message"].as_str().unwrap_or_default(); assert!( @@ -278,7 +294,6 @@ async fn restart_should_not_shutdown_when_replacement_spawn_fails() { #[tokio::test] async fn update_should_replace_local_release_files_with_latest_asset() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-update-route"; let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); @@ -288,9 +303,9 @@ async fn update_should_replace_local_release_files_with_latest_asset() { std::fs::write(&exe_path, "old-binary").unwrap(); std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); mount_latest_release_with_archive(&github, repository, "0.4.0", "new-binary", "new-web").await; - set_system_update_env(repository, &github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update", update_config).await; let response = app .clone() @@ -328,23 +343,21 @@ async fn update_should_replace_local_release_files_with_latest_asset() { #[tokio::test] async fn update_should_reject_when_confirmed_target_differs_from_remote_latest() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-update-target-changed-route"; - let initial_github = MockServer::start().await; - let latest_github = MockServer::start().await; + let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); let exe_path = deploy.path().join("codex-proxy-rs"); let web_dist = deploy.path().join("web/dist"); std::fs::create_dir_all(&web_dist).unwrap(); std::fs::write(&exe_path, "old-binary").unwrap(); std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); - mount_latest_release(&initial_github, repository, "0.4.0").await; - mount_latest_release(&latest_github, repository, "0.5.0").await; - set_system_update_env(repository, &initial_github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update-target-changed").await; + mount_latest_release(&github, repository, "0.4.0").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-target-changed", update_config).await; let initial = get_update_detail(&app, true, "req_system_update_target_initial").await; - set_system_update_env(repository, &latest_github.uri()); + github.reset().await; + mount_latest_release(&github, repository, "0.5.0").await; let response = app .clone() @@ -374,19 +387,19 @@ async fn update_should_reject_when_confirmed_target_differs_from_remote_latest() #[tokio::test] async fn update_should_reject_untrusted_github_api_base() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let deploy = tempfile::tempdir().unwrap(); let exe_path = deploy.path().join("codex-proxy-rs"); let web_dist = deploy.path().join("web/dist"); std::fs::create_dir_all(&web_dist).unwrap(); std::fs::write(&exe_path, "old-binary").unwrap(); std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); - set_system_update_env( + let mut update_config = system_update_config( "zyycn/codex-proxy-rs-update-untrusted-api-base-route", "https://mirror.example", ); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update-untrusted-api-base").await; + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = + admin_system_test_app("system-update-untrusted-api-base", update_config).await; let response = app .oneshot( @@ -413,7 +426,6 @@ async fn update_should_reject_untrusted_github_api_base() { #[tokio::test] async fn update_should_fail_when_release_checksum_is_missing() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-missing-checksum-route"; let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); @@ -430,9 +442,9 @@ async fn update_should_fail_when_release_checksum_is_missing() { "new-web", ) .await; - set_system_update_env(repository, &github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update-missing-checksum").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-missing-checksum", update_config).await; assert_update_failure_preserves_files( &app, @@ -447,7 +459,6 @@ async fn update_should_fail_when_release_checksum_is_missing() { #[tokio::test] async fn update_should_fail_when_release_checksum_mismatches() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-bad-checksum-route"; let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); @@ -464,9 +475,9 @@ async fn update_should_fail_when_release_checksum_mismatches() { "new-web", ) .await; - set_system_update_env(repository, &github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update-bad-checksum").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-bad-checksum", update_config).await; assert_update_failure_preserves_files( &app, @@ -481,7 +492,6 @@ async fn update_should_fail_when_release_checksum_mismatches() { #[tokio::test] async fn update_should_reject_release_archive_from_untrusted_host() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-untrusted-download-route"; let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); @@ -498,9 +508,10 @@ async fn update_should_reject_release_archive_from_untrusted_host() { "https://github.com/zyycn/codex-proxy-rs/releases/download/v0.4.0/checksums.txt", ) .await; - set_system_update_env(repository, &github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update-untrusted-download").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = + admin_system_test_app("system-update-untrusted-download", update_config).await; assert_update_failure_preserves_files( &app, @@ -514,8 +525,7 @@ async fn update_should_reject_release_archive_from_untrusted_host() { } #[tokio::test] -async fn update_should_reject_insecure_release_archive_even_when_env_flag_is_set() { - let _guard = SYSTEM_ENV_LOCK.lock().await; +async fn update_should_reject_insecure_release_archive() { let repository = "zyycn/codex-proxy-rs-insecure-download-route"; let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); @@ -532,10 +542,9 @@ async fn update_should_reject_insecure_release_archive_even_when_env_flag_is_set "https://github.com/zyycn/codex-proxy-rs/releases/download/v0.4.0/checksums.txt", ) .await; - set_system_update_env(repository, &github.uri()); - std::env::set_var("CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS", "true"); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update-insecure-download").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-insecure-download", update_config).await; assert_update_failure_preserves_files( &app, @@ -550,7 +559,6 @@ async fn update_should_reject_insecure_release_archive_even_when_env_flag_is_set #[tokio::test] async fn update_should_reject_release_archive_with_unsafe_path() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-unsafe-archive-route"; let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); @@ -561,9 +569,9 @@ async fn update_should_reject_release_archive_with_unsafe_path() { std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); mount_latest_release_with_unsafe_archive(&github, repository, "0.4.0", "new-binary", "new-web") .await; - set_system_update_env(repository, &github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update-unsafe-archive").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-unsafe-archive", update_config).await; assert_update_failure_preserves_files( &app, @@ -578,7 +586,6 @@ async fn update_should_reject_release_archive_with_unsafe_path() { #[tokio::test] async fn update_should_restore_web_assets_when_binary_backup_fails() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-binary-backup-failure-route"; let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); @@ -589,9 +596,10 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() { std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); std::fs::create_dir(deploy.path().join("codex-proxy-rs.backup")).unwrap(); mount_latest_release_with_archive(&github, repository, "0.4.0", "new-binary", "new-web").await; - set_system_update_env(repository, &github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update-binary-backup-failure").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = + admin_system_test_app("system-update-binary-backup-failure", update_config).await; let response = app .clone() @@ -629,7 +637,6 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() { #[tokio::test] async fn update_should_remove_stale_file_lock_and_continue() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-stale-lock-route"; let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); @@ -648,9 +655,9 @@ async fn update_should_remove_stale_file_lock_and_continue() { .set_modified(SystemTime::now() - Duration::from_secs(31 * 60)) .unwrap(); mount_latest_release_with_archive(&github, repository, "0.4.0", "new-binary", "new-web").await; - set_system_update_env(repository, &github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-update-stale-lock").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-update-stale-lock", update_config).await; let response = app .clone() @@ -680,7 +687,6 @@ async fn update_should_remove_stale_file_lock_and_continue() { #[tokio::test] async fn update_should_replace_web_assets_across_filesystems() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let Ok(web_root) = tempfile::tempdir_in("/dev/shm") else { return; }; @@ -693,9 +699,9 @@ async fn update_should_replace_web_assets_across_filesystems() { std::fs::write(&exe_path, "old-binary").unwrap(); std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); mount_latest_release_with_archive(&github, repository, "0.4.0", "new-binary", "new-web").await; - set_system_update_env(repository, &github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-cross-device-update").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-cross-device-update", update_config).await; let response = app .clone() @@ -726,10 +732,11 @@ async fn update_should_replace_web_assets_across_filesystems() { #[tokio::test] async fn update_status_should_read_local_update_state() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let deploy = tempfile::tempdir().unwrap(); - set_system_update_env("zyycn/codex-proxy-rs-status-route", "http://127.0.0.1:9"); - set_system_update_paths( + let mut update_config = + system_update_config("zyycn/codex-proxy-rs-status-route", "http://127.0.0.1:9"); + configure_system_update_paths( + &mut update_config, deploy.path(), &deploy.path().join("codex-proxy-rs"), &deploy.path().join("web/dist"), @@ -754,7 +761,7 @@ async fn update_status_should_read_local_update_state() { .to_string(), ) .unwrap(); - let (app, _dir) = admin_system_test_app("system-status").await; + let (app, _dir) = admin_system_test_app("system-status", update_config).await; let response = app .oneshot( @@ -779,15 +786,16 @@ async fn update_status_should_read_local_update_state() { #[tokio::test] async fn update_events_should_open_authenticated_sse_stream() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let deploy = tempfile::tempdir().unwrap(); - set_system_update_env("zyycn/codex-proxy-rs-events-route", "http://127.0.0.1:9"); - set_system_update_paths( + let mut update_config = + system_update_config("zyycn/codex-proxy-rs-events-route", "http://127.0.0.1:9"); + configure_system_update_paths( + &mut update_config, deploy.path(), &deploy.path().join("codex-proxy-rs"), &deploy.path().join("web/dist"), ); - let (app, _dir) = admin_system_test_app("system-events").await; + let (app, _dir) = admin_system_test_app("system-events", update_config).await; let response = app .oneshot( @@ -812,18 +820,19 @@ async fn update_events_should_open_authenticated_sse_stream() { #[tokio::test] async fn update_events_should_close_on_shutdown() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let deploy = tempfile::tempdir().unwrap(); - set_system_update_env( + let mut update_config = system_update_config( "zyycn/codex-proxy-rs-events-shutdown-route", "http://127.0.0.1:9", ); - set_system_update_paths( + configure_system_update_paths( + &mut update_config, deploy.path(), &deploy.path().join("codex-proxy-rs"), &deploy.path().join("web/dist"), ); - let (app, _dir) = admin_system_test_app("system-events-shutdown").await; + let (app, _dir, process_control) = + admin_system_test_app_with_process_control("system-events-shutdown", update_config).await; let response = app .oneshot( @@ -837,7 +846,7 @@ async fn update_events_should_close_on_shutdown() { ) .await .unwrap(); - codex_proxy_rs::bootstrap::shutdown::request_shutdown(); + process_control.signal_shutdown(); let body = tokio::time::timeout(Duration::from_secs(2), to_bytes(response.into_body(), 1024)) .await @@ -849,7 +858,6 @@ async fn update_events_should_close_on_shutdown() { #[tokio::test] async fn update_events_should_close_after_terminal_update_log() { - let _guard = SYSTEM_ENV_LOCK.lock().await; let repository = "zyycn/codex-proxy-rs-events-terminal-route"; let github = MockServer::start().await; let deploy = tempfile::tempdir().unwrap(); @@ -859,9 +867,9 @@ async fn update_events_should_close_after_terminal_update_log() { std::fs::write(&exe_path, "old-binary").unwrap(); std::fs::write(web_dist.join("index.html"), "old-web").unwrap(); mount_latest_release_with_archive(&github, repository, "0.4.0", "new-binary", "new-web").await; - set_system_update_env(repository, &github.uri()); - set_system_update_paths(deploy.path(), &exe_path, &web_dist); - let (app, _dir) = admin_system_test_app("system-events-terminal").await; + let mut update_config = system_update_config(repository, &github.uri()); + configure_system_update_paths(&mut update_config, deploy.path(), &exe_path, &web_dist); + let (app, _dir) = admin_system_test_app("system-events-terminal", update_config).await; let sse_response = app .clone() @@ -1217,31 +1225,36 @@ fn append_raw_tar_file(builder: &mut Builder, path: &str, bytes: &[ builder.append(&header, bytes).unwrap(); } -fn set_system_update_env(repository: &str, github_api_base: &str) { - std::env::set_var("CPR_VERSION", "0.1.0"); - std::env::set_var("CPR_DEPLOYMENT_MODE", "docker"); - std::env::set_var("CPR_BUILD_TYPE", "release"); - std::env::set_var("CPR_UPDATE_CHANNEL", "stable"); - std::env::set_var("CPR_UPDATE_REPOSITORY", repository); - std::env::set_var("CPR_GITHUB_API_BASE", format!("{github_api_base}/repos")); - std::env::remove_var("CPR_UPDATE_ALLOW_INSECURE_DOWNLOADS"); - std::env::remove_var("CPR_UPDATE_EXE_PATH"); - std::env::remove_var("CPR_WEB_DIST_DIR"); - std::env::remove_var("CPR_UPDATE_STATE_FILE"); - std::env::remove_var("CPR_UPDATE_LOCK_FILE"); - std::env::remove_var("CPR_UPDATE_TEMP_DIR"); - std::env::remove_var("CPR_ENABLE_SELF_RESTART"); +fn system_update_config(repository: &str, github_api_base: &str) -> SystemUpdateConfig { + SystemUpdateConfig { + version: VERSION_OLD.to_string(), + git_sha: "test-git-sha".to_string(), + build_time: "2026-07-01T00:00:00Z".to_string(), + deployment_mode: "docker".to_string(), + build_type: "release".to_string(), + update_channel: "stable".to_string(), + update_repository: Some(repository.to_string()), + github_api_base: format!("{github_api_base}/repos"), + executable_path: Some(PathBuf::from("/app/bin/codex-proxy-rs")), + web_dist_dir: PathBuf::from("/app/web/dist"), + update_state_file: PathBuf::from("/app/data/update-state.json"), + update_lock_file: PathBuf::from("/app/data/update-state.lock"), + update_temp_dir: PathBuf::from("/app/data/update-tmp"), + self_restart_enabled: false, + } } -fn set_system_update_paths(root: &Path, exe_path: &Path, web_dist: &Path) { - std::env::set_var("CPR_UPDATE_EXE_PATH", exe_path); - std::env::set_var("CPR_WEB_DIST_DIR", web_dist); - std::env::set_var( - "CPR_UPDATE_STATE_FILE", - root.join(".runtime/update-state.json"), - ); - std::env::set_var("CPR_UPDATE_LOCK_FILE", root.join(".runtime/update.lock")); - std::env::set_var("CPR_UPDATE_TEMP_DIR", root.join(".runtime/update-tmp")); +fn configure_system_update_paths( + config: &mut SystemUpdateConfig, + root: &Path, + exe_path: &Path, + web_dist: &Path, +) { + config.executable_path = Some(exe_path.to_path_buf()); + config.web_dist_dir = web_dist.to_path_buf(); + config.update_state_file = root.join(".runtime/update-state.json"); + config.update_lock_file = root.join(".runtime/update.lock"); + config.update_temp_dir = root.join(".runtime/update-tmp"); } async fn wait_for_operation_status(app: &axum::Router, expected: &str) -> Value { @@ -1271,7 +1284,21 @@ async fn wait_for_operation_status(app: &axum::Router, expected: &str) -> Value async fn admin_system_test_app( db_name: &str, + update_config: SystemUpdateConfig, ) -> (axum::Router, crate::support::storage::TestDatabaseGuard) { + let (app, dir, _process_control) = + admin_system_test_app_with_process_control(db_name, update_config).await; + (app, dir) +} + +async fn admin_system_test_app_with_process_control( + db_name: &str, + update_config: SystemUpdateConfig, +) -> ( + axum::Router, + crate::support::storage::TestDatabaseGuard, + Arc, +) { let (pool, dir) = crate::support::storage::init_test_db(db_name).await; let redis = crate::support::storage::create_test_redis(db_name).await; seed_admin_session(&pool, &redis, "session_1").await; @@ -1282,7 +1309,10 @@ async fn admin_system_test_app( stores, runtime_fingerprint(test_fingerprint()), )); - let state = AppState::from(services.as_ref()); + let mut state = AppState::from(services.as_ref()); + state.services.system_update = Arc::new(SystemUpdateService::new(update_config)); + let process_control = Arc::new(TestProcessControl::new()); + state.services.process_control = process_control.clone(); let app = codex_proxy_rs::api::router::router().with_state(state); - (app, dir) + (app, dir, process_control) } diff --git a/backend/tests/api/admin/usage_routes.rs b/backend/tests/api/admin/usage_routes.rs index 9438a8547..4f754c971 100644 --- a/backend/tests/api/admin/usage_routes.rs +++ b/backend/tests/api/admin/usage_routes.rs @@ -10,7 +10,7 @@ use codex_proxy_rs::{ usage::types::UsageRecord, }, }; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use tower::util::ServiceExt; use crate::support::{ diff --git a/backend/tests/api/assets.rs b/backend/tests/api/assets.rs index af2c481ee..556824232 100644 --- a/backend/tests/api/assets.rs +++ b/backend/tests/api/assets.rs @@ -1,9 +1,9 @@ use std::path::Path; use axum::{ - body::{to_bytes, Body}, - http::{header, Request, StatusCode}, Router, + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, }; use codex_proxy_rs::bootstrap::config::AppConfig; use serde_json::Value; @@ -15,8 +15,8 @@ use crate::support::{ fingerprint::runtime_fingerprint, http::response_json, storage::{ - background_task_stores, create_test_redis, init_test_db, test_database_url, - TestDatabaseGuard, + TestDatabaseGuard, background_task_stores, create_test_redis, init_test_db, + test_database_url, }, }; @@ -163,12 +163,16 @@ async fn assert_unknown_api_route(response: axum::response::Response) { fn assert_no_static_policy_headers(response: &axum::response::Response) { assert!(!response.headers().contains_key(header::CACHE_CONTROL)); - assert!(!response - .headers() - .contains_key(header::CONTENT_SECURITY_POLICY)); - assert!(!response - .headers() - .contains_key(header::X_CONTENT_TYPE_OPTIONS)); + assert!( + !response + .headers() + .contains_key(header::CONTENT_SECURITY_POLICY) + ); + assert!( + !response + .headers() + .contains_key(header::X_CONTENT_TYPE_OPTIONS) + ); assert!(!response.headers().contains_key("x-frame-options")); assert!(!response.headers().contains_key("referrer-policy")); } diff --git a/backend/tests/api/client/auth.rs b/backend/tests/api/client/auth.rs index e2a9069fe..7c74daa8a 100644 --- a/backend/tests/api/client/auth.rs +++ b/backend/tests/api/client/auth.rs @@ -1,5 +1,5 @@ -use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue}; -use codex_proxy_rs::api::client::auth::{bearer_client_api_key, ClientApiKeyAuthError}; +use axum::http::{HeaderMap, HeaderValue, header::AUTHORIZATION}; +use codex_proxy_rs::api::client::auth::{ClientApiKeyAuthError, bearer_client_api_key}; #[test] fn bearer_client_api_key_should_report_missing_authorization() { diff --git a/backend/tests/api/client/models.rs b/backend/tests/api/client/models.rs index 9af5a14ee..cdf6ab4b0 100644 --- a/backend/tests/api/client/models.rs +++ b/backend/tests/api/client/models.rs @@ -1,9 +1,9 @@ use std::collections::BTreeMap; use axum::{ + Router, body::Body, http::{Request, StatusCode}, - Router, }; use codex_proxy_rs::{api::AppState, bootstrap::services::Services}; use tower::util::ServiceExt; @@ -75,11 +75,13 @@ async fn models_route_should_accept_stored_client_api_key() { .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = response_json(response).await; - assert!(body["data"] - .as_array() - .unwrap() - .iter() - .any(|model| model["id"] == "gpt-5.5")); + assert!( + body["data"] + .as_array() + .unwrap() + .iter() + .any(|model| model["id"] == "gpt-5.5") + ); } #[tokio::test] diff --git a/backend/tests/api/client/responses.rs b/backend/tests/api/client/responses.rs index 1eb0211eb..6af645d16 100644 --- a/backend/tests/api/client/responses.rs +++ b/backend/tests/api/client/responses.rs @@ -1,5 +1,5 @@ use axum::{ - body::{to_bytes, Body}, + body::{Body, to_bytes}, http::{Request, StatusCode}, }; use codex_proxy_rs::{api::AppState, bootstrap::services::Services}; diff --git a/backend/tests/api/middleware/trace.rs b/backend/tests/api/middleware/trace.rs index cdca789ed..fd3917a71 100644 --- a/backend/tests/api/middleware/trace.rs +++ b/backend/tests/api/middleware/trace.rs @@ -5,20 +5,20 @@ use std::{ }; use axum::{ - body::{to_bytes, Body}, + Extension, Router, + body::{Body, to_bytes}, extract::connect_info::ConnectInfo, http::HeaderMap, http::{Request, StatusCode}, middleware::from_fn, routing::get, - Extension, Router, }; use codex_proxy_rs::api::middleware::{ - request_id::{attach_request_id, ClientIp}, + request_id::{ClientIp, attach_request_id}, trace::http_trace_layer, }; use serde_json::Value; -use tokio::time::{sleep, Duration}; +use tokio::time::{Duration, sleep}; use tower::ServiceExt; use tracing_subscriber::fmt::MakeWriter; diff --git a/backend/tests/bootstrap/import_sqlite/mod.rs b/backend/tests/bootstrap/import_sqlite/mod.rs index 6f2b769b8..529a9802f 100644 --- a/backend/tests/bootstrap/import_sqlite/mod.rs +++ b/backend/tests/bootstrap/import_sqlite/mod.rs @@ -1,10 +1,10 @@ use std::path::PathBuf; use codex_proxy_rs::{ - bootstrap::import_sqlite::{import_sqlite, ImportSqliteError}, + bootstrap::import_sqlite::{ImportSqliteError, import_sqlite}, keys::{service::KeyVerifier, store::PgClientKeyStore}, }; -use sqlx::{sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions, Row}; +use sqlx::{Row, sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions}; use crate::support::storage::init_test_db; @@ -116,7 +116,7 @@ async fn import_sqlite_should_rollback_all_target_writes_on_invalid_source_value let error = import_sqlite(&target, &source_path).await.unwrap_err(); - assert!(matches!(error, ImportSqliteError::InvalidTimestamp { .. })); + std::assert_matches!(error, ImportSqliteError::InvalidTimestamp { .. }); let rows: i64 = sqlx::query_scalar("select count(*) from admin_users") .fetch_one(&target) .await @@ -137,10 +137,10 @@ async fn import_sqlite_should_reject_unknown_source_version() { let error = import_sqlite(&target, &source_path).await.unwrap_err(); - assert!(matches!( + std::assert_matches!( error, ImportSqliteError::UnsupportedSourceVersion { actual: Some(2) } - )); + ); } #[tokio::test] @@ -159,7 +159,7 @@ async fn import_sqlite_should_reject_nonempty_target() { let error = import_sqlite(&target, &source_path).await.unwrap_err(); - assert!(matches!(error, ImportSqliteError::TargetNotEmpty)); + std::assert_matches!(error, ImportSqliteError::TargetNotEmpty); } #[tokio::test] diff --git a/backend/tests/bootstrap/services.rs b/backend/tests/bootstrap/services.rs index 980be50b1..dce3dc58d 100644 --- a/backend/tests/bootstrap/services.rs +++ b/backend/tests/bootstrap/services.rs @@ -25,9 +25,11 @@ async fn services_try_new_should_use_configured_tls_transport_builder() { ) else { panic!("invalid custom CA should fail service transport construction"); }; - assert!(error - .to_string() - .contains("Failed to read CA certificate file")); + assert!( + error + .to_string() + .contains("Failed to read CA certificate file") + ); return; } diff --git a/backend/tests/bootstrap/tasks/mod.rs b/backend/tests/bootstrap/tasks/mod.rs index e5f78365a..f7406cb5a 100644 --- a/backend/tests/bootstrap/tasks/mod.rs +++ b/backend/tests/bootstrap/tasks/mod.rs @@ -13,8 +13,8 @@ use codex_proxy_rs::telemetry::account_usage::store::{ }; use codex_proxy_rs::upstream::openai::fingerprint::{PgFingerprintStore, RuntimeFingerprint}; use wiremock::{ - matchers::{method, path}, Mock, MockServer, ResponseTemplate, + matchers::{method, path}, }; use crate::support::storage::init_test_db; diff --git a/backend/tests/dispatch/affinity/mod.rs b/backend/tests/dispatch/affinity/mod.rs index d1000299b..299945b86 100644 --- a/backend/tests/dispatch/affinity/mod.rs +++ b/backend/tests/dispatch/affinity/mod.rs @@ -1,7 +1,7 @@ use chrono::{Duration, Utc}; use codex_proxy_rs::dispatch::affinity::{ - RedisSessionAffinityStore, ResponseReplaySnapshot, SessionAffinityEntry, MAX_REPLAY_DEPTH, - MAX_REPLAY_SESSION_BYTES, + MAX_REPLAY_DEPTH, MAX_REPLAY_SESSION_BYTES, RedisSessionAffinityStore, ResponseReplaySnapshot, + SessionAffinityEntry, }; use redis::AsyncCommands; use serde_json::json; @@ -32,11 +32,13 @@ async fn redis_affinity_store_upserts_reads_and_forgets_entries() { .unwrap(); assert_eq!(loaded, entry); assert!(store.forget("resp_1").await.unwrap()); - assert!(store - .get("resp_1", now, Duration::hours(4)) - .await - .unwrap() - .is_none()); + assert!( + store + .get("resp_1", now, Duration::hours(4)) + .await + .unwrap() + .is_none() + ); } #[tokio::test] @@ -110,11 +112,13 @@ async fn redis_affinity_store_forgets_all_entries_for_account() { assert_eq!(store.forget_account("acct_delete").await.unwrap(), 2); for response_id in ["resp_1", "resp_2"] { - assert!(store - .get(response_id, now, Duration::hours(4)) - .await - .unwrap() - .is_none()); + assert!( + store + .get(response_id, now, Duration::hours(4)) + .await + .unwrap() + .is_none() + ); } } @@ -199,18 +203,20 @@ async fn redis_affinity_store_should_reject_replay_metadata_over_capacity() { .await .unwrap(); - assert!(store - .replay_input( - "resp_limit", - &entry, - now, - Duration::hours(4), - MAX_REPLAY_DEPTH, - MAX_REPLAY_SESSION_BYTES, - ) - .await - .unwrap() - .is_none()); + assert!( + store + .replay_input( + "resp_limit", + &entry, + now, + Duration::hours(4), + MAX_REPLAY_DEPTH, + MAX_REPLAY_SESSION_BYTES, + ) + .await + .unwrap() + .is_none() + ); } #[tokio::test] @@ -265,11 +271,13 @@ async fn redis_affinity_store_should_not_persist_already_expired_entries() { ) .await .unwrap(); - assert!(store - .get("resp_expired", now, Duration::hours(1)) - .await - .unwrap() - .is_none()); + assert!( + store + .get("resp_expired", now, Duration::hours(1)) + .await + .unwrap() + .is_none() + ); } fn affinity( diff --git a/backend/tests/dispatch/service.rs b/backend/tests/dispatch/service.rs index 350ac5272..3e8570209 100644 --- a/backend/tests/dispatch/service.rs +++ b/backend/tests/dispatch/service.rs @@ -1,19 +1,19 @@ use std::{ sync::{ - atomic::{AtomicUsize, Ordering}, Arc, Mutex, + atomic::{AtomicUsize, Ordering}, }, time::{Duration as StdDuration, Instant}, }; use axum::{ - body::{to_bytes, Body, Bytes}, + body::{Body, Bytes, to_bytes}, http::{Request, StatusCode}, }; use chrono::{Duration, Utc}; use codex_proxy_rs::{ - api::router, api::AppState, + api::router, bootstrap::config::AppConfig, bootstrap::services::{Services, UsageRecordOptions}, dispatch::affinity::{ResponseReplaySnapshot, SessionAffinityEntry}, @@ -22,7 +22,7 @@ use codex_proxy_rs::{ telemetry::usage::store::{PgUsageRecordStore, UsageRecordFilter}, }; use futures::{SinkExt, StreamExt}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use sqlx::PgPool; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, @@ -31,21 +31,20 @@ use tokio::{ time::timeout, }; use tokio_tungstenite::{ - accept_hdr_async_with_config, + WebSocketStream, accept_hdr_async_with_config, tungstenite::{ - extensions::{compression::deflate::DeflateConfig, ExtensionsConfig}, + Error as WsError, Message, + extensions::{ExtensionsConfig, compression::deflate::DeflateConfig}, handshake::server::{ Callback, ErrorResponse, Request as WsRequest, Response as WsResponse, }, protocol::WebSocketConfig, - Error as WsError, Message, }, - WebSocketStream, }; use tower::util::ServiceExt; use wiremock::{ - matchers::{header, method, path}, Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, }; use crate::support::{ diff --git a/backend/tests/dispatch/service/compact_routes.rs b/backend/tests/dispatch/service/compact_routes.rs index 75399d27b..e15626cda 100644 --- a/backend/tests/dispatch/service/compact_routes.rs +++ b/backend/tests/dispatch/service/compact_routes.rs @@ -185,10 +185,12 @@ async fn responses_compact_should_post_json_to_codex_compact_upstream() { .and_then(|value| value.to_str().ok()), Some("text/event-stream") ); - assert!(compact_request - .headers - .get("x-codex-installation-id") - .is_some()); + assert!( + compact_request + .headers + .get("x-codex-installation-id") + .is_some() + ); let upstream_body: Value = serde_json::from_slice(&compact_request.body).unwrap(); assert_eq!(upstream_body["model"], "gpt-5.5"); assert_eq!(upstream_body["instructions"], "compress the session"); @@ -206,9 +208,11 @@ async fn responses_compact_should_post_json_to_codex_compact_upstream() { // compact 只剥离 stream;prompt_cache_key 按账号作用域伪名化。 assert!(upstream_body.get("stream").is_none()); assert_eq!(upstream_body["store"], true); - assert!(upstream_body["prompt_cache_key"] - .as_str() - .is_some_and(|value| value.starts_with("wi_") && value != "session-seed")); + assert!( + upstream_body["prompt_cache_key"] + .as_str() + .is_some_and(|value| value.starts_with("wi_") && value != "session-seed") + ); assert_eq!(upstream_body["input"].as_array().unwrap().len(), 4); assert_eq!(upstream_body["input"][1]["ignored"], "drop"); assert_eq!( @@ -290,9 +294,11 @@ async fn responses_compact_should_return_rate_limit_error_when_fallback_is_exhau assert_eq!(metadata["failed"], true); assert_eq!(event.failure_class.as_deref(), Some("rate_limited")); assert_eq!(metadata["exhaustedCount"], 1); - assert!(metadata["upstreamError"] - .as_str() - .is_some_and(|value| value.contains("compact quota reached"))); + assert!( + metadata["upstreamError"] + .as_str() + .is_some_and(|value| value.contains("compact quota reached")) + ); } #[tokio::test] @@ -353,9 +359,11 @@ async fn responses_compact_should_preserve_upstream_client_error_status() { assert_eq!(metadata["failed"], true); assert_eq!(event.failure_class.as_deref(), Some("upstream")); assert_eq!(event.upstream_status_code, Some(400)); - assert!(metadata["error"] - .as_str() - .is_some_and(|value| value.contains("invalid_encrypted_content"))); + assert!( + metadata["error"] + .as_str() + .is_some_and(|value| value.contains("invalid_encrypted_content")) + ); } #[tokio::test] diff --git a/backend/tests/dispatch/service/responses_http.rs b/backend/tests/dispatch/service/responses_http.rs index 89c02f444..2b2f3b5f6 100644 --- a/backend/tests/dispatch/service/responses_http.rs +++ b/backend/tests/dispatch/service/responses_http.rs @@ -108,10 +108,12 @@ async fn responses_should_honor_explicit_http_sse_transport() { .and_then(|value| value.to_str().ok()) .map(ToString::to_string); assert!(response.headers().get("set-cookie").is_none()); - assert!(response - .headers() - .get("x-codex-primary-used-percent") - .is_none()); + assert!( + response + .headers() + .get("x-codex-primary-used-percent") + .is_none() + ); let body = response_text(response).await; let requests = server.received_requests().await.unwrap(); let upstream_body: Value = serde_json::from_slice(&requests[0].body).unwrap(); @@ -287,9 +289,11 @@ async fn responses_should_use_websocket_upstream_by_default_while_serving_sse() .as_object() .expect("official websocket timing metadata should be present"); assert_eq!(metadata.len(), 1); - assert!(metadata["x-codex-ws-stream-request-start-ms"] - .as_str() - .is_some_and(|value| value.parse::().is_ok())); + assert!( + metadata["x-codex-ws-stream-request-start-ms"] + .as_str() + .is_some_and(|value| value.parse::().is_ok()) + ); } #[tokio::test] @@ -981,9 +985,11 @@ async fn responses_stream_should_close_http_sse_upstream_when_client_disconnects .expect("first SSE chunk should arrive before disconnect") .expect("stream should yield a first chunk") .expect("chunk should be readable"); - assert!(String::from_utf8(first_chunk.to_vec()) - .unwrap() - .contains("event: response.output_text.delta")); + assert!( + String::from_utf8(first_chunk.to_vec()) + .unwrap() + .contains("event: response.output_text.delta") + ); drop(body); assert!( @@ -1071,8 +1077,8 @@ async fn responses_stream_should_forward_first_chunk_before_upstream_completes() } #[tokio::test] -async fn responses_stream_should_emit_failed_event_after_upstream_read_error_once_downstream_started( -) { +async fn responses_stream_should_emit_failed_event_after_upstream_read_error_once_downstream_started() + { let (base_url, first_chunk_sent, close_upstream) = spawn_chunked_sse_upstream_then_abrupt_close(include_str!( "../../fixtures/responses/http_sse/partial_transport_failure.sse" @@ -1113,9 +1119,11 @@ async fn responses_stream_should_emit_failed_event_after_upstream_read_error_onc .expect("first proxied SSE chunk should arrive before upstream closes") .unwrap() .unwrap(); - assert!(String::from_utf8(first_chunk.to_vec()) - .unwrap() - .contains("partial before transport failure")); + assert!( + String::from_utf8(first_chunk.to_vec()) + .unwrap() + .contains("partial before transport failure") + ); close_upstream.send(()).unwrap(); let rest = collect_stream_body(body_stream).await; @@ -1166,9 +1174,11 @@ async fn responses_stream_should_emit_failed_event_when_upstream_closes_without_ .expect("first proxied SSE chunk should arrive before upstream closes") .unwrap() .unwrap(); - assert!(String::from_utf8(first_chunk.to_vec()) - .unwrap() - .contains("partial before clean close")); + assert!( + String::from_utf8(first_chunk.to_vec()) + .unwrap() + .contains("partial before clean close") + ); close_upstream.send(()).unwrap(); let rest = collect_stream_body(body_stream).await; diff --git a/backend/tests/dispatch/service/responses_recovery.rs b/backend/tests/dispatch/service/responses_recovery.rs index b296e91c0..376cfaaa1 100644 --- a/backend/tests/dispatch/service/responses_recovery.rs +++ b/backend/tests/dispatch/service/responses_recovery.rs @@ -1121,10 +1121,12 @@ async fn responses_explicit_previous_response_should_fail_without_transparent_re assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["error"]["code"], "previous_response_unavailable"); - assert!(body["error"]["message"] - .as_str() - .unwrap() - .contains("resend the complete input")); + assert!( + body["error"]["message"] + .as_str() + .unwrap() + .contains("resend the complete input") + ); assert_eq!(captured.payload["previous_response_id"], "resp_stale"); assert!(!captured.retry_attempted); } @@ -2398,9 +2400,11 @@ async fn responses_should_return_rate_limit_error_when_429_fallback_is_exhausted assert_eq!(event.transport.as_deref(), Some("http_sse")); assert_eq!(event.failure_class.as_deref(), Some("rate_limited")); assert_eq!(metadata["exhaustedCount"], 1); - assert!(metadata["upstreamError"] - .as_str() - .is_some_and(|error| error.contains("rate limited"))); + assert!( + metadata["upstreamError"] + .as_str() + .is_some_and(|error| error.contains("rate limited")) + ); } #[tokio::test] @@ -2481,9 +2485,11 @@ async fn responses_stream_should_return_rate_limit_error_when_429_fallback_is_ex assert_eq!(event.transport.as_deref(), Some("http_sse")); assert_eq!(event.failure_class.as_deref(), Some("rate_limited")); assert_eq!(metadata["exhaustedCount"], 1); - assert!(metadata["upstreamError"] - .as_str() - .is_some_and(|error| error.contains("rate limited"))); + assert!( + metadata["upstreamError"] + .as_str() + .is_some_and(|error| error.contains("rate limited")) + ); } #[tokio::test] diff --git a/backend/tests/dispatch/service/responses_websocket.rs b/backend/tests/dispatch/service/responses_websocket.rs index b29639303..aad054de3 100644 --- a/backend/tests/dispatch/service/responses_websocket.rs +++ b/backend/tests/dispatch/service/responses_websocket.rs @@ -313,9 +313,11 @@ async fn responses_websocket_should_reuse_connection_for_recorded_conversation() .await .unwrap(); assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_pool_first\"")); + assert!( + response_text(first_response) + .await + .contains("\"id\":\"resp_pool_first\"") + ); let second_response = app .oneshot( @@ -337,9 +339,11 @@ async fn responses_websocket_should_reuse_connection_for_recorded_conversation() .await .unwrap(); assert_eq!(second_response.status(), StatusCode::OK); - assert!(response_text(second_response) - .await - .contains("\"id\":\"resp_pool_second\"")); + assert!( + response_text(second_response) + .await + .contains("\"id\":\"resp_pool_second\"") + ); let (reused_connection, first_payload, second_payload) = upstream.await.unwrap(); assert!(reused_connection, "second request opened a new websocket"); @@ -395,9 +399,11 @@ async fn responses_websocket_should_replay_history_when_reused_connection_dies_b .await .unwrap(); assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_stale_reuse_first\"")); + assert!( + response_text(first_response) + .await + .contains("\"id\":\"resp_stale_reuse_first\"") + ); let second_response = app .oneshot(responses_json_request( @@ -432,8 +438,8 @@ async fn responses_websocket_should_replay_history_when_reused_connection_dies_b } #[tokio::test] -async fn responses_websocket_stream_should_replay_managed_history_after_previous_response_not_found( -) { +async fn responses_websocket_stream_should_replay_managed_history_after_previous_response_not_found() + { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let upstream = tokio::spawn(async move { @@ -745,9 +751,11 @@ async fn responses_websocket_should_not_reuse_connection_when_pool_is_disabled() .await .unwrap(); assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_disabled_pool_first\"")); + assert!( + response_text(first_response) + .await + .contains("\"id\":\"resp_disabled_pool_first\"") + ); let second_response = app .oneshot( @@ -769,9 +777,11 @@ async fn responses_websocket_should_not_reuse_connection_when_pool_is_disabled() .await .unwrap(); assert_eq!(second_response.status(), StatusCode::OK); - assert!(response_text(second_response) - .await - .contains("\"id\":\"resp_disabled_pool_second\"")); + assert!( + response_text(second_response) + .await + .contains("\"id\":\"resp_disabled_pool_second\"") + ); let (reused_connection, first_payload, second_payload) = upstream.await.unwrap(); assert!( @@ -889,9 +899,11 @@ async fn responses_websocket_stream_should_strip_turn_state_when_replaying_on_ne .await .unwrap(); assert_eq!(second_response.status(), StatusCode::OK); - assert!(response_text(second_response) - .await - .contains("\"id\":\"resp_metadata_turn_state_next\"")); + assert!( + response_text(second_response) + .await + .contains("\"id\":\"resp_metadata_turn_state_next\"") + ); let (first_payload, second_payload, second_headers) = upstream.await.unwrap(); assert!(first_payload.get("previous_response_id").is_none()); @@ -965,9 +977,11 @@ async fn responses_websocket_pool_should_be_evicted_after_admin_account_status_c .await .unwrap(); assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_pool_status_first\"")); + assert!( + response_text(first_response) + .await + .contains("\"id\":\"resp_pool_status_first\"") + ); update_admin_account_status(&app, "acct_chat", "disabled").await; update_admin_account_status(&app, "acct_chat", "active").await; @@ -984,9 +998,11 @@ async fn responses_websocket_pool_should_be_evicted_after_admin_account_status_c .await .unwrap(); assert_eq!(second_response.status(), StatusCode::OK); - assert!(response_text(second_response) - .await - .contains("\"id\":\"resp_pool_status_second\"")); + assert!( + response_text(second_response) + .await + .contains("\"id\":\"resp_pool_status_second\"") + ); let reused_connection = upstream.await.unwrap(); assert!( @@ -1028,9 +1044,11 @@ async fn responses_websocket_should_route_previous_response_id_to_recorded_accou .await .unwrap(); assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_affinity_first\"")); + assert!( + response_text(first_response) + .await + .contains("\"id\":\"resp_affinity_first\"") + ); let stored_affinity = state .services .session_affinity @@ -1107,9 +1125,11 @@ async fn responses_websocket_should_prefer_conversation_account_without_previous .await .unwrap(); assert_eq!(first_response.status(), StatusCode::OK); - assert!(response_text(first_response) - .await - .contains("\"id\":\"resp_conversation_seed\"")); + assert!( + response_text(first_response) + .await + .contains("\"id\":\"resp_conversation_seed\"") + ); assert!( state .services @@ -1126,9 +1146,11 @@ async fn responses_websocket_should_prefer_conversation_account_without_previous let (_reused, first_payload, second_payload) = upstream.await.unwrap(); assert!(body.contains("\"id\":\"resp_conversation_affinity\"")); - assert!(first_payload["prompt_cache_key"] - .as_str() - .is_some_and(|value| value.starts_with("wi_"))); + assert!( + first_payload["prompt_cache_key"] + .as_str() + .is_some_and(|value| value.starts_with("wi_")) + ); assert_eq!( second_payload["prompt_cache_key"], first_payload["prompt_cache_key"] @@ -1346,8 +1368,8 @@ async fn responses_websocket_without_history_should_mark_expired_after_fallback_ } #[tokio::test] -async fn responses_websocket_without_history_should_return_rate_limit_stream_error_when_fallback_accounts_exhausted( -) { +async fn responses_websocket_without_history_should_return_rate_limit_stream_error_when_fallback_accounts_exhausted() + { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let upstream = tokio::spawn(async move { @@ -1475,8 +1497,8 @@ async fn responses_websocket_response_failed_quota_should_retry_fallback_account } #[tokio::test] -async fn responses_websocket_without_history_should_return_quota_stream_error_when_402_has_no_fallback( -) { +async fn responses_websocket_without_history_should_return_quota_stream_error_when_402_has_no_fallback() + { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let upstream = tokio::spawn(async move { @@ -1527,8 +1549,8 @@ async fn responses_websocket_without_history_should_return_quota_stream_error_wh } #[tokio::test] -async fn responses_websocket_without_history_should_return_model_unsupported_stream_error_when_no_fallback( -) { +async fn responses_websocket_without_history_should_return_model_unsupported_stream_error_when_no_fallback() + { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let upstream = tokio::spawn(async move { @@ -1691,8 +1713,8 @@ async fn responses_with_previous_response_id_should_use_websocket_and_configured } #[tokio::test] -async fn responses_stream_with_previous_response_id_should_forward_websocket_chunks_before_completion( -) { +async fn responses_stream_with_previous_response_id_should_forward_websocket_chunks_before_completion() + { let (base_url, first_chunk_sent_rx, finish_tx, upstream) = spawn_chunked_websocket_upstream().await; let (app, api_key, _dir) = test_app_with_account(base_url).await; diff --git a/backend/tests/dispatch/service/usage_logging.rs b/backend/tests/dispatch/service/usage_logging.rs index e9c60d9ab..1083a5157 100644 --- a/backend/tests/dispatch/service/usage_logging.rs +++ b/backend/tests/dispatch/service/usage_logging.rs @@ -727,9 +727,11 @@ async fn responses_stream_should_preserve_body_metadata_when_capture_body_enable ); assert_eq!(metadata["requestBody"]["model"], "gpt-5.5"); assert_eq!(metadata["requestBody"]["stream"], true); - assert!(metadata["responseBody"] - .as_str() - .is_some_and(|body| body.contains("resp_stream_usage"))); + assert!( + metadata["responseBody"] + .as_str() + .is_some_and(|body| body.contains("resp_stream_usage")) + ); } #[tokio::test] @@ -842,9 +844,11 @@ async fn responses_stream_should_record_failure_detail_after_transport_error() { assert_eq!(metadata["failureSource"], "proxy"); assert_eq!(metadata["synthetic"], true); - assert!(metadata["failureDetail"] - .as_str() - .is_some_and(|detail| !detail.trim().is_empty())); + assert!( + metadata["failureDetail"] + .as_str() + .is_some_and(|detail| !detail.trim().is_empty()) + ); } #[tokio::test] diff --git a/backend/tests/fleet/pool/quota.rs b/backend/tests/fleet/pool/quota.rs index c5bc3a268..0d4caa930 100644 --- a/backend/tests/fleet/pool/quota.rs +++ b/backend/tests/fleet/pool/quota.rs @@ -28,9 +28,10 @@ fn account_pool_should_mark_quota_state_as_quota_exhausted_status() { let limited = pool.get("limited").unwrap(); assert_eq!(limited.status, AccountStatus::QuotaExhausted); assert!(limited.quota_limit_reached); - assert!(pool - .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) - .is_none()); + assert!( + pool.acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .is_none() + ); pool.apply_quota_state("limited", false, None); @@ -65,9 +66,10 @@ fn account_pool_should_reuse_quota_limited_accounts_after_cooldown() { )); pool.mark_quota_limited_until("limited", now + Duration::seconds(30)); - assert!(pool - .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) - .is_none()); + assert!( + pool.acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .is_none() + ); assert_eq!( pool.acquire_with(&AccountAcquireRequest::new( "gpt-5.5", @@ -92,12 +94,13 @@ fn account_pool_should_not_shorten_existing_quota_cooldown() { pool.mark_quota_limited_until("limited", now + Duration::seconds(180)); pool.mark_quota_limited_until("limited", now + Duration::seconds(60)); - assert!(pool - .acquire_with(&AccountAcquireRequest::new( + assert!( + pool.acquire_with(&AccountAcquireRequest::new( "gpt-5.5", now + Duration::seconds(90) )) - .is_none()); + .is_none() + ); } #[test] @@ -143,10 +146,12 @@ fn acquire_should_refresh_expired_cooldowns_before_selecting_account() { assert!(acquired.account.quota_cooldown_until.is_none()); assert!(acquired.account.cloudflare_cooldown_until.is_none()); assert_eq!(acquired.account.window_request_count, 0); - assert!(acquired - .account - .window_reset_at - .is_some_and(|reset| reset > now)); + assert!( + acquired + .account + .window_reset_at + .is_some_and(|reset| reset > now) + ); } #[test] diff --git a/backend/tests/fleet/pool/selection.rs b/backend/tests/fleet/pool/selection.rs index b662ed322..8ab6c0921 100644 --- a/backend/tests/fleet/pool/selection.rs +++ b/backend/tests/fleet/pool/selection.rs @@ -519,11 +519,12 @@ fn account_pool_should_not_fallback_when_required_account_is_unavailable() { AccountStatus::Active, )); - assert!(pool - .acquire_with( + assert!( + pool.acquire_with( &AccountAcquireRequest::new("gpt-5.5", fixed_time()).with_required_account_id("acct_a"), ) - .is_some()); + .is_some() + ); let acquired = pool.acquire_with( &AccountAcquireRequest::new("gpt-5.5", fixed_time()).with_required_account_id("acct_a"), ); @@ -544,9 +545,10 @@ fn account_pool_should_cleanup_stale_slots_before_acquire() { AccountStatus::Active, )); - assert!(pool - .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) - .is_some()); + assert!( + pool.acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .is_some() + ); let acquired = pool .acquire_with(&AccountAcquireRequest::new( "gpt-5.5", diff --git a/backend/tests/fleet/quota.rs b/backend/tests/fleet/quota.rs index 88b4d3e42..a47d3f8c5 100644 --- a/backend/tests/fleet/quota.rs +++ b/backend/tests/fleet/quota.rs @@ -4,18 +4,18 @@ use chrono::{Duration, Utc}; use codex_proxy_rs::fleet::account::AccountStatus; use codex_proxy_rs::fleet::cookies::PgCookieStore; use codex_proxy_rs::fleet::quota::{ - quota_from_usage, quota_snapshot_limit_reached, quota_snapshot_limit_window_seconds, - quota_snapshot_reset_at, QuotaRefreshService, + QuotaRefreshService, quota_from_usage, quota_snapshot_limit_reached, + quota_snapshot_limit_window_seconds, quota_snapshot_reset_at, }; use codex_proxy_rs::fleet::store::{NewAccount, PgAccountStore}; use codex_proxy_rs::infra::identity::AccountPseudonymizer; use codex_proxy_rs::telemetry::account_usage::store::PgAccountUsageStore; use codex_proxy_rs::upstream::openai::transport::CodexBackendClient; use secrecy::SecretString; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use wiremock::{ - matchers::{method, path}, Mock, MockServer, ResponseTemplate, + matchers::{method, path}, }; use crate::support::storage::init_test_db; diff --git a/backend/tests/fleet/refresh/lease.rs b/backend/tests/fleet/refresh/lease.rs index ea632454f..d2cf03c6e 100644 --- a/backend/tests/fleet/refresh/lease.rs +++ b/backend/tests/fleet/refresh/lease.rs @@ -8,38 +8,50 @@ async fn redis_refresh_lease_acquire_renew_and_compare_release_are_atomic() { let leases = RedisRefreshLeaseStore::new(create_test_redis("refresh-lease-atomic").await); let now = Utc::now(); - assert!(leases - .try_acquire("acct-lease", "owner-a", now + Duration::minutes(5), now) - .await - .unwrap()); - assert!(!leases - .try_acquire("acct-lease", "owner-b", now + Duration::minutes(5), now) - .await - .unwrap()); - assert!(leases - .try_acquire("acct-lease", "owner-a", now + Duration::minutes(10), now,) - .await - .unwrap()); + assert!( + leases + .try_acquire("acct-lease", "owner-a", now + Duration::minutes(5), now) + .await + .unwrap() + ); + assert!( + !leases + .try_acquire("acct-lease", "owner-b", now + Duration::minutes(5), now) + .await + .unwrap() + ); + assert!( + leases + .try_acquire("acct-lease", "owner-a", now + Duration::minutes(10), now,) + .await + .unwrap() + ); assert!(!leases.release("acct-lease", "owner-b").await.unwrap()); assert!(leases.release("acct-lease", "owner-a").await.unwrap()); - assert!(leases - .try_acquire("acct-lease", "owner-b", now + Duration::minutes(5), now) - .await - .unwrap()); + assert!( + leases + .try_acquire("acct-lease", "owner-b", now + Duration::minutes(5), now) + .await + .unwrap() + ); } #[tokio::test] async fn redis_refresh_lease_lists_only_existing_ttl_keys() { let leases = RedisRefreshLeaseStore::new(create_test_redis("refresh-lease-list").await); let now = Utc::now(); - assert!(leases - .try_acquire("acct-active", "owner", now + Duration::minutes(5), now) - .await - .unwrap()); - assert!(!leases - .try_acquire("acct-expired", "owner", now - Duration::seconds(1), now) - .await - .unwrap()); + assert!( + leases + .try_acquire("acct-active", "owner", now + Duration::minutes(5), now) + .await + .unwrap() + ); + assert!( + !leases + .try_acquire("acct-expired", "owner", now - Duration::seconds(1), now) + .await + .unwrap() + ); let ids = vec![ "acct-active".to_string(), diff --git a/backend/tests/fleet/refresh/mod.rs b/backend/tests/fleet/refresh/mod.rs index aae9a73c2..6269cdb72 100644 --- a/backend/tests/fleet/refresh/mod.rs +++ b/backend/tests/fleet/refresh/mod.rs @@ -1,8 +1,8 @@ use std::{ collections::VecDeque, sync::{ - atomic::{AtomicUsize, Ordering}, Arc, + atomic::{AtomicUsize, Ordering}, }, time::Duration as StdDuration, }; diff --git a/backend/tests/fleet/refresh/scheduling.rs b/backend/tests/fleet/refresh/scheduling.rs index 9b55008e7..b33a7d5c5 100644 --- a/backend/tests/fleet/refresh/scheduling.rs +++ b/backend/tests/fleet/refresh/scheduling.rs @@ -23,15 +23,17 @@ async fn token_refresh_task_should_skip_account_when_refresh_lease_is_held() { }) .await .expect("account should be inserted"); - assert!(leases - .try_acquire( - "acct-lease-held", - "external-owner", - now + Duration::minutes(5), - now, - ) - .await - .expect("external owner should acquire lease")); + assert!( + leases + .try_acquire( + "acct-lease-held", + "external-owner", + now + Duration::minutes(5), + now, + ) + .await + .expect("external owner should acquire lease") + ); let refresher = CountingTokenRefresher::default(); let task = codex_proxy_rs::fleet::refresh::TokenRefreshService::new( store.clone(), @@ -199,9 +201,11 @@ async fn token_refresh_shutdown_should_wait_for_in_flight_refresh() { let shutdown_task = task.clone(); let shutdown = tokio::spawn(async move { shutdown_task.shutdown().await }); tokio::pin!(shutdown); - assert!(timeout(StdDuration::from_millis(100), &mut shutdown) - .await - .is_err()); + assert!( + timeout(StdDuration::from_millis(100), &mut shutdown) + .await + .is_err() + ); refresher.release.notify_waiters(); timeout(StdDuration::from_secs(2), &mut shutdown) @@ -319,10 +323,12 @@ async fn token_refresh_task_should_cancel_future_timer_without_refreshing_expire .await .expect("future timer should be scheduled"); assert_eq!(task.scheduled_timer_count().await, 1); - assert!(store - .set_status("acct-refresh-unauthorized-now", AccountStatus::Expired) - .await - .expect("expired status should persist")); + assert!( + store + .set_status("acct-refresh-unauthorized-now", AccountStatus::Expired) + .await + .expect("expired status should persist") + ); task.schedule_account_timers_once_at(now) .await diff --git a/backend/tests/fleet/scheduler/score.rs b/backend/tests/fleet/scheduler/score.rs index 0b2d99487..a6a06fc3e 100644 --- a/backend/tests/fleet/scheduler/score.rs +++ b/backend/tests/fleet/scheduler/score.rs @@ -1,6 +1,6 @@ use chrono::Utc; use codex_proxy_rs::fleet::account::AccountStatus; -use codex_proxy_rs::fleet::scheduler::{rank_candidates, FeedbackStats, ScoreWeights}; +use codex_proxy_rs::fleet::scheduler::{FeedbackStats, ScoreWeights, rank_candidates}; use crate::support::accounts::test_account; diff --git a/backend/tests/fleet/store/mod.rs b/backend/tests/fleet/store/mod.rs index 63481f95a..f7980d5e4 100644 --- a/backend/tests/fleet/store/mod.rs +++ b/backend/tests/fleet/store/mod.rs @@ -186,9 +186,11 @@ async fn account_store_page_should_return_row_mapping_errors_instead_of_skipping .list_metadata_page(1, 20, None) .await .expect_err("invalid rows must fail the page instead of disappearing"); - assert!(error - .to_string() - .contains("PostgreSQL account store status error: corrupt")); + assert!( + error + .to_string() + .contains("PostgreSQL account store status error: corrupt") + ); } #[tokio::test] @@ -278,14 +280,16 @@ async fn account_store_should_update_status_and_label_without_rewriting_tokens() .await .unwrap(); - assert!(repo - .set_status("acct_a", AccountStatus::Disabled) - .await - .unwrap()); - assert!(repo - .set_label("acct_a", Some("work".to_string())) - .await - .unwrap()); + assert!( + repo.set_status("acct_a", AccountStatus::Disabled) + .await + .unwrap() + ); + assert!( + repo.set_label("acct_a", Some("work".to_string())) + .await + .unwrap() + ); let after: (String,) = sqlx::query_as("select access_token from accounts where id = $1") .bind("acct_a") @@ -319,8 +323,8 @@ async fn account_store_should_not_reactivate_disabled_or_banned_accounts_from_cl .expect("banned status should persist"); for account_id in ["acct_disabled", "acct_banned"] { - assert!(repo - .update_from_claims( + assert!( + repo.update_from_claims( account_id, AccountClaimsUpdate { email: Some(format!("{account_id}@example.com")), @@ -337,7 +341,8 @@ async fn account_store_should_not_reactivate_disabled_or_banned_accounts_from_cl }, ) .await - .expect("claims update should persist")); + .expect("claims update should persist") + ); } let disabled = repo diff --git a/backend/tests/infra/database.rs b/backend/tests/infra/database.rs index 80ce01575..9678a5fb0 100644 --- a/backend/tests/infra/database.rs +++ b/backend/tests/infra/database.rs @@ -117,21 +117,25 @@ async fn postgres_schema_separates_success_and_error_facts() { assert!(error_columns.contains(column), "missing {column}"); } - assert!(sqlx::query( - "insert into usage_records ( + assert!( + sqlx::query( + "insert into usage_records ( id, kind, provider, account_id, model, status_code, message, metadata_json, created_at ) values ('bad', 'request', 'openai', 'acct', 'gpt-5', 500, 'bad', '{}'::jsonb, now())", - ) - .execute(&pool) - .await - .is_err()); - assert!(sqlx::query( - "insert into ops_error_logs (id, kind, status_code, message, metadata_json, created_at) + ) + .execute(&pool) + .await + .is_err() + ); + assert!( + sqlx::query( + "insert into ops_error_logs (id, kind, status_code, message, metadata_json, created_at) values ('bad', 'request', 700, 'bad', '{}'::jsonb, now())", - ) - .execute(&pool) - .await - .is_err()); + ) + .execute(&pool) + .await + .is_err() + ); } #[tokio::test] @@ -167,21 +171,25 @@ async fn postgres_schema_has_required_fact_and_bucket_indexes() { #[tokio::test] async fn postgres_schema_enforces_account_and_counter_constraints() { let (pool, _guard) = init_test_db("schema-constraints").await; - assert!(sqlx::query( - "insert into accounts (id, access_token, status, added_at, updated_at) + assert!( + sqlx::query( + "insert into accounts (id, access_token, status, added_at, updated_at) values ('acct_bad', 'token', 'refreshing', now(), now())", - ) - .execute(&pool) - .await - .is_err()); - assert!(sqlx::query( - "insert into request_time_buckets ( + ) + .execute(&pool) + .await + .is_err() + ); + assert!( + sqlx::query( + "insert into request_time_buckets ( bucket_start, provider, account_id, model, service_tier, success_count ) values (now(), 'openai', 'acct', 'gpt-5', '__unknown__', -1)", - ) - .execute(&pool) - .await - .is_err()); + ) + .execute(&pool) + .await + .is_err() + ); sqlx::query( "insert into runtime_settings ( id, refresh_margin_seconds, refresh_concurrency, diff --git a/backend/tests/infra/logging.rs b/backend/tests/infra/logging.rs index 0a21b765d..a1a94ec6d 100644 --- a/backend/tests/infra/logging.rs +++ b/backend/tests/infra/logging.rs @@ -2,7 +2,7 @@ use std::{fs, io::Write}; use chrono::{Duration, Utc}; use codex_proxy_rs::infra::{ - logging::{build_file_appender, init_tracing, RotationConfig, TracingConfig}, + logging::{RotationConfig, TracingConfig, build_file_appender, init_tracing}, time::china_date, }; @@ -68,10 +68,11 @@ fn rolling_appender_should_remove_logs_outside_calendar_retention() { let _appender = build_file_appender(&config).unwrap(); assert!(!dir.path().join(managed_log_name(&old_date, 0)).exists()); - assert!(dir - .path() - .join(managed_log_name(&retained_date, 0)) - .exists()); + assert!( + dir.path() + .join(managed_log_name(&retained_date, 0)) + .exists() + ); assert!(dir.path().join("other.log").exists()); } diff --git a/backend/tests/infra/paths.rs b/backend/tests/infra/paths.rs index c805a4bba..c45dd3c40 100644 --- a/backend/tests/infra/paths.rs +++ b/backend/tests/infra/paths.rs @@ -1,20 +1,34 @@ -use std::{ffi::OsString, fs, sync::Mutex}; +use std::{fs, path::PathBuf, process::Command}; use codex_proxy_rs::infra::paths::{ensure_data_dir, load_or_create_identity_secret}; const IDENTITY_SECRET_FILE_NAME: &str = "identity_hmac_secret"; - -static ENV_LOCK: Mutex<()> = Mutex::new(()); +const DATA_DIR_CASE_ENV: &str = "CODEX_PROXY_TEST_DATA_DIR_CASE"; #[test] fn data_dir_should_use_xdg_data_home_directly() { - let _guard = ENV_LOCK.lock().unwrap(); + if std::env::var(DATA_DIR_CASE_ENV).as_deref() == Ok("child") { + let expected = PathBuf::from(std::env::var_os("XDG_DATA_HOME").unwrap()); + assert_eq!(ensure_data_dir().unwrap(), expected); + return; + } + let dir = tempfile::tempdir().unwrap(); - let _xdg_data_home = EnvVarGuard::set("XDG_DATA_HOME", dir.path().as_os_str().to_owned()); + let output = Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg("infra::paths::data_dir_should_use_xdg_data_home_directly") + .arg("--nocapture") + .env(DATA_DIR_CASE_ENV, "child") + .env("XDG_DATA_HOME", dir.path()) + .output() + .unwrap(); - let resolved = ensure_data_dir().unwrap(); - - assert_eq!(resolved, dir.path()); + assert!( + output.status.success(), + "isolated data directory test failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); } #[test] @@ -46,25 +60,3 @@ fn identity_secret_should_fail_closed_when_existing_file_is_invalid() { assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); } - -struct EnvVarGuard { - key: &'static str, - previous: Option, -} - -impl EnvVarGuard { - fn set(key: &'static str, value: OsString) -> Self { - let previous = std::env::var_os(key); - std::env::set_var(key, value); - Self { key, previous } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - match &self.previous { - Some(value) => std::env::set_var(self.key, value), - None => std::env::remove_var(self.key), - } - } -} diff --git a/backend/tests/models/catalog.rs b/backend/tests/models/catalog.rs index 450809e90..e50536256 100644 --- a/backend/tests/models/catalog.rs +++ b/backend/tests/models/catalog.rs @@ -1,8 +1,8 @@ use std::{ collections::BTreeMap, sync::{ - atomic::{AtomicUsize, Ordering}, Arc, + atomic::{AtomicUsize, Ordering}, }, }; @@ -29,9 +29,11 @@ fn model_catalog_should_resolve_alias_chain_to_model_id() { let catalog = ModelCatalog::from_config(&ModelConfig { model_aliases }); // 透明代理:只解析 alias,不再拆分 `-high`/`-flex` 等后缀。 - assert!(catalog - .public_model_ids() - .contains(&"codex-fast".to_string())); + assert!( + catalog + .public_model_ids() + .contains(&"codex-fast".to_string()) + ); assert_eq!( catalog.model_info_for_name("codex-fast").unwrap().id, "gpt-5.5" @@ -201,7 +203,7 @@ async fn model_service_should_return_no_accounts_when_no_plan_accounts_exist() { .await .expect_err("refresh should fail"); - assert!(matches!(error, ModelServiceError::NoAccounts)); + std::assert_matches!(error, ModelServiceError::NoAccounts); } #[tokio::test] diff --git a/backend/tests/support/jwt.rs b/backend/tests/support/jwt.rs index 43ff3d4f4..614e0ba9b 100644 --- a/backend/tests/support/jwt.rs +++ b/backend/tests/support/jwt.rs @@ -1,5 +1,5 @@ -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; -use serde_json::{json, Value}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use serde_json::{Value, json}; pub(crate) fn unsigned_jwt(payload: &Value) -> String { let header = json!({"alg": "none", "typ": "JWT"}); diff --git a/backend/tests/support/storage.rs b/backend/tests/support/storage.rs index 82378f720..3e92236e6 100644 --- a/backend/tests/support/storage.rs +++ b/backend/tests/support/storage.rs @@ -15,7 +15,7 @@ use codex_proxy_rs::{ }, upstream::openai::fingerprint::PgFingerprintStore, }; -use sqlx::{postgres::PgPoolOptions, Connection, PgConnection, PgPool}; +use sqlx::{Connection, PgConnection, PgPool, postgres::PgPoolOptions}; use uuid::Uuid; const DEFAULT_TEST_DATABASE_URL: &str = diff --git a/backend/tests/upstream/openai/protocol/mod.rs b/backend/tests/upstream/openai/protocol/mod.rs index 1fecdbce8..985e993ce 100644 --- a/backend/tests/upstream/openai/protocol/mod.rs +++ b/backend/tests/upstream/openai/protocol/mod.rs @@ -1,22 +1,23 @@ use axum::http::HeaderMap; use codex_proxy_rs::api::client::responses::{build_codex_request, build_compact_request}; use codex_proxy_rs::upstream::openai::protocol::events::{ - extract_sse_usage, extract_usage, parse_rate_limit_headers, parse_rate_limits_event, - rate_limit_quota, retry_after_seconds_from_body, RateLimitWindow, TokenUsage, + RateLimitWindow, TokenUsage, extract_sse_usage, extract_usage, parse_rate_limit_headers, + parse_rate_limits_event, rate_limit_quota, retry_after_seconds_from_body, }; use codex_proxy_rs::upstream::openai::protocol::responses::{ - http_sse_fallback_allowed, response_body_has_first_output, response_from_codex_sse, - response_sse_event_is_terminal, transport_for_request, CodexResponsesRequest, CodexTransport, - CollectedResponse, + CodexResponsesRequest, CodexTransport, CollectedResponse, http_sse_fallback_allowed, + response_body_has_first_output, response_from_codex_sse, response_sse_event_is_terminal, + transport_for_request, }; use codex_proxy_rs::upstream::openai::protocol::sse::{ parse_sse_events, response_failed_sse_event, }; use codex_proxy_rs::upstream::openai::protocol::websocket::{ - classify_websocket_error_frame, is_terminal_websocket_event, - retry_after_seconds_from_wrapped_error_headers, websocket_audit_artifact_from_attempt, - websocket_event_to_sse_frame, websocket_metadata_turn_state, websocket_payload_audit_snapshot, - websocket_response_completed_id, OpeningAuditHeader, OpeningAuditSnapshot, + OpeningAuditHeader, OpeningAuditSnapshot, classify_websocket_error_frame, + is_terminal_websocket_event, retry_after_seconds_from_wrapped_error_headers, + websocket_audit_artifact_from_attempt, websocket_event_to_sse_frame, + websocket_metadata_turn_state, websocket_payload_audit_snapshot, + websocket_response_completed_id, }; use serde_json::json; diff --git a/backend/tests/upstream/openai/protocol/responses.rs b/backend/tests/upstream/openai/protocol/responses.rs index 24320b13b..0fc4a6b01 100644 --- a/backend/tests/upstream/openai/protocol/responses.rs +++ b/backend/tests/upstream/openai/protocol/responses.rs @@ -147,9 +147,11 @@ fn response_failed_sse_event_should_encode_openai_failure_shape() { assert_eq!(data["response"]["error"]["code"], "stream_disconnected"); assert_eq!(data["response"]["error"]["message"], "closed early"); assert_eq!(data["error"], data["response"]["error"]); - assert!(data["response"]["id"] - .as_str() - .is_some_and(|id| id.starts_with("resp_proxy_"))); + assert!( + data["response"]["id"] + .as_str() + .is_some_and(|id| id.starts_with("resp_proxy_")) + ); } fn responses_body(value: serde_json::Value) -> serde_json::Map { diff --git a/backend/tests/upstream/openai/protocol/websocket.rs b/backend/tests/upstream/openai/protocol/websocket.rs index 52ecb45e7..2f0ee0c81 100644 --- a/backend/tests/upstream/openai/protocol/websocket.rs +++ b/backend/tests/upstream/openai/protocol/websocket.rs @@ -428,12 +428,16 @@ fn codex_websocket_response_completed_id_should_reject_missing_id_and_incomplete }) .to_string(); - assert!(websocket_response_completed_id(&missing_id) - .expect_err("missing id should be rejected") - .contains("missing field")); - assert!(websocket_response_completed_id(&incomplete_usage) - .expect_err("incomplete usage should be rejected") - .contains("missing field")); + assert!( + websocket_response_completed_id(&missing_id) + .expect_err("missing id should be rejected") + .contains("missing field") + ); + assert!( + websocket_response_completed_id(&incomplete_usage) + .expect_err("incomplete usage should be rejected") + .contains("missing field") + ); } #[test] diff --git a/backend/tests/upstream/openai/token_client.rs b/backend/tests/upstream/openai/token_client.rs index 2659e7c00..43049a100 100644 --- a/backend/tests/upstream/openai/token_client.rs +++ b/backend/tests/upstream/openai/token_client.rs @@ -1,6 +1,6 @@ use wiremock::{ - matchers::{method, path}, Mock, MockServer, ResponseTemplate, + matchers::{method, path}, }; use codex_proxy_rs::upstream::openai::token_client::{ diff --git a/backend/tests/upstream/openai/transport/client.rs b/backend/tests/upstream/openai/transport/client.rs index 5906476b1..3b1c9588e 100644 --- a/backend/tests/upstream/openai/transport/client.rs +++ b/backend/tests/upstream/openai/transport/client.rs @@ -1,6 +1,6 @@ use std::sync::{ - atomic::{AtomicUsize, Ordering}, Arc, + atomic::{AtomicUsize, Ordering}, }; use super::{accept_codex_test_websocket, completed_websocket_response, request_context}; diff --git a/backend/tests/upstream/openai/transport/headers.rs b/backend/tests/upstream/openai/transport/headers.rs index 4ce1f4963..ca15efa3e 100644 --- a/backend/tests/upstream/openai/transport/headers.rs +++ b/backend/tests/upstream/openai/transport/headers.rs @@ -214,10 +214,12 @@ async fn websocket_execute_response_create_request_should_capture_handshake_head response.set_cookie_headers, vec!["cf_clearance=ws; Domain=.chatgpt.com; Path=/".to_string()] ); - assert!(response - .rate_limit_headers - .iter() - .any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "41")); + assert!( + response + .rate_limit_headers + .iter() + .any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "41") + ); } #[tokio::test] @@ -343,39 +345,57 @@ async fn codex_backend_client_websocket_should_forward_security_chain_headers_an ); let headers = received_headers.lock().unwrap().clone(); - assert!(headers - .iter() - .any(|(name, value)| name == "x-client-request-id" && value == "req_ws_security")); - assert!(headers - .iter() - .any(|(name, value)| name == "x-codex-installation-id" && value == "install-123")); - assert!(headers - .iter() - .any(|(name, value)| name == "x-openai-internal-codex-residency" && value == "us")); - assert!(headers - .iter() - .any(|(name, value)| name == "x-codex-turn-state" && value == "turn-state")); + assert!( + headers + .iter() + .any(|(name, value)| name == "x-client-request-id" && value == "req_ws_security") + ); + assert!( + headers + .iter() + .any(|(name, value)| name == "x-codex-installation-id" && value == "install-123") + ); + assert!( + headers + .iter() + .any(|(name, value)| name == "x-openai-internal-codex-residency" && value == "us") + ); + assert!( + headers + .iter() + .any(|(name, value)| name == "x-codex-turn-state" && value == "turn-state") + ); assert!(headers.iter().any(|(name, value)| { name == "x-codex-turn-metadata" && value == "{\"thread_source\":\"subagent\"}" })); - assert!(headers - .iter() - .any(|(name, value)| name == "x-codex-beta-features" && value == "feature-a")); + assert!( + headers + .iter() + .any(|(name, value)| name == "x-codex-beta-features" && value == "feature-a") + ); assert!(headers.iter().any(|(name, value)| { name == "x-responsesapi-include-timing-metrics" && value == "true" })); - assert!(headers - .iter() - .any(|(name, value)| name == "version" && value == "26.318.11754")); - assert!(headers - .iter() - .any(|(name, value)| name == "x-codex-parent-thread-id" && value == "parent-456")); - assert!(headers - .iter() - .any(|(name, value)| name == "x-openai-subagent" && value == "review")); - assert!(headers - .iter() - .any(|(name, value)| name == "session-id" && value == "cp_derived")); + assert!( + headers + .iter() + .any(|(name, value)| name == "version" && value == "26.318.11754") + ); + assert!( + headers + .iter() + .any(|(name, value)| name == "x-codex-parent-thread-id" && value == "parent-456") + ); + assert!( + headers + .iter() + .any(|(name, value)| name == "x-openai-subagent" && value == "review") + ); + assert!( + headers + .iter() + .any(|(name, value)| name == "session-id" && value == "cp_derived") + ); assert!(headers.iter().all(|(name, _)| name != "thread-id")); assert!(headers.iter().all(|(name, _)| name != "content-type")); assert!(headers.iter().all(|(name, _)| name != "accept")); diff --git a/backend/tests/upstream/openai/transport/mod.rs b/backend/tests/upstream/openai/transport/mod.rs index b737cbd33..dcfcd2c9b 100644 --- a/backend/tests/upstream/openai/transport/mod.rs +++ b/backend/tests/upstream/openai/transport/mod.rs @@ -1,8 +1,8 @@ use std::{ process::Command, sync::{ - atomic::{AtomicUsize, Ordering}, Arc, Mutex, + atomic::{AtomicUsize, Ordering}, }, time::Duration, }; @@ -10,8 +10,8 @@ use std::{ use codex_proxy_rs::upstream::openai::{ protocol::responses::{PreviousResponseScope, StreamCommitPolicy}, transport::websocket::{ - execute_response_create_request, responses_websocket_endpoint, CodexWebSocketConnection, - CodexWebSocketExchangeError, CodexWebSocketRequest, + CodexWebSocketConnection, CodexWebSocketExchangeError, CodexWebSocketRequest, + execute_response_create_request, responses_websocket_endpoint, }, transport::websocket_pool::{CodexWebSocketPool, CodexWebSocketPoolConfig}, transport::{CodexBackendClient, CodexClientError, CodexRequestContext}, @@ -26,12 +26,12 @@ use tokio::{ use tokio_tungstenite::{ accept_hdr_async_with_config, tungstenite::{ - extensions::{compression::deflate::DeflateConfig, ExtensionsConfig}, + Message, + extensions::{ExtensionsConfig, compression::deflate::DeflateConfig}, handshake::server::{ Callback, ErrorResponse, Request as WsRequest, Response as WsResponse, }, protocol::WebSocketConfig, - Message, }, }; diff --git a/backend/tests/upstream/openai/transport/websocket.rs b/backend/tests/upstream/openai/transport/websocket.rs index ce3d97777..81fe4c5c0 100644 --- a/backend/tests/upstream/openai/transport/websocket.rs +++ b/backend/tests/upstream/openai/transport/websocket.rs @@ -152,9 +152,11 @@ async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_ server.await.unwrap(); assert!(response.body.contains("hello from websocket")); - assert!(response - .body - .contains("resp_6f8d0c2b5a4e4a0d9c1b7e3f2a8d5c6b")); + assert!( + response + .body + .contains("resp_6f8d0c2b5a4e4a0d9c1b7e3f2a8d5c6b") + ); } #[test] @@ -297,8 +299,8 @@ async fn websocket_execute_response_create_request_should_collect_completed_sse( } #[tokio::test] -async fn websocket_execute_response_create_request_should_surface_response_failed_as_upstream_error( -) { +async fn websocket_execute_response_create_request_should_surface_response_failed_as_upstream_error() + { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -394,8 +396,8 @@ async fn websocket_execute_response_create_request_should_pass_through_unmapped_ } #[tokio::test] -async fn websocket_execute_response_create_request_should_preserve_opening_error_status_body_and_retry_after( -) { +async fn websocket_execute_response_create_request_should_preserve_opening_error_status_body_and_retry_after() + { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -451,15 +453,12 @@ async fn websocket_execute_response_create_request_should_reject_binary_event() .expect_err("binary websocket events should be rejected"); server.await.unwrap(); - assert!(matches!( - error, - CodexWebSocketExchangeError::UnexpectedBinaryEvent - )); + std::assert_matches!(error, CodexWebSocketExchangeError::UnexpectedBinaryEvent); } #[tokio::test] -async fn websocket_execute_response_create_request_should_surface_wrapped_error_status_and_retry_after( -) { +async fn websocket_execute_response_create_request_should_surface_wrapped_error_status_and_retry_after() + { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -605,8 +604,8 @@ async fn websocket_execute_response_create_request_should_forward_typed_events_w } #[tokio::test] -async fn websocket_execute_response_create_request_should_capture_internal_metadata_and_rate_limit_events( -) { +async fn websocket_execute_response_create_request_should_capture_internal_metadata_and_rate_limit_events() + { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { @@ -676,14 +675,18 @@ async fn websocket_execute_response_create_request_should_capture_internal_metad assert!(!response.body.contains("codex.rate_limits")); assert!(!response.body.contains("response.metadata")); assert!(response.body.contains("event: response.completed")); - assert!(response - .rate_limit_headers - .iter() - .any(|(name, value)| name == "x-codex-primary-used-percent" && value == "100")); - assert!(response - .rate_limit_headers - .iter() - .any(|(name, value)| name == "x-codex-primary-reset-at" && value == "1893456300")); + assert!( + response + .rate_limit_headers + .iter() + .any(|(name, value)| name == "x-codex-primary-used-percent" && value == "100") + ); + assert!( + response + .rate_limit_headers + .iter() + .any(|(name, value)| name == "x-codex-primary-reset-at" && value == "1893456300") + ); } #[tokio::test] @@ -905,10 +908,10 @@ async fn websocket_execute_response_create_request_should_reject_completed_witho .expect_err("completed frame without response must be rejected"); server.await.unwrap(); - assert!(matches!( + std::assert_matches!( error, CodexWebSocketExchangeError::InvalidCompletedResponse { .. } - )); + ); } #[tokio::test] @@ -1002,11 +1005,11 @@ async fn codex_backend_client_should_timeout_when_upstream_is_silent() { .expect_err("silent upstream should time out"); server.abort(); - assert!(matches!( + std::assert_matches!( error, CodexClientError::WebSocket(CodexWebSocketExchangeError::InitialEventTimeout { timeout }) if timeout == Duration::from_millis(30) - )); + ); } #[tokio::test(start_paused = true)] @@ -1167,10 +1170,10 @@ async fn codex_backend_client_stream_should_reject_binary_websocket_event() { .expect_err("binary frame should fail the stream"); server.await.unwrap(); - assert!(matches!( + std::assert_matches!( error, CodexClientError::WebSocket(CodexWebSocketExchangeError::UnexpectedBinaryEvent) - )); + ); } #[tokio::test] @@ -1308,10 +1311,10 @@ async fn codex_backend_client_stream_should_error_when_websocket_closes_before_t .expect_err("close before terminal should be an error"); server.await.unwrap(); - assert!(matches!( + std::assert_matches!( error, CodexClientError::WebSocket(CodexWebSocketExchangeError::ClosedBeforeTerminal) - )); + ); } #[tokio::test(start_paused = true)] @@ -1381,9 +1384,11 @@ async fn codex_backend_client_stream_should_wait_for_terminal_after_active_webso .expect("terminal frame should be valid"); server.await.unwrap(); - assert!(std::str::from_utf8(&terminal) - .unwrap() - .contains("resp_after_active_gap")); + assert!( + std::str::from_utf8(&terminal) + .unwrap() + .contains("resp_after_active_gap") + ); } #[tokio::test(start_paused = true)] @@ -1446,11 +1451,11 @@ async fn codex_backend_client_stream_should_timeout_when_active_websocket_stalls .expect_err("active stall should time out"); server.abort(); - assert!(matches!( + std::assert_matches!( error, CodexClientError::WebSocket(CodexWebSocketExchangeError::ReceiveIdleTimeout { timeout }) if timeout == Duration::from_secs(5 * 60) - )); + ); } #[tokio::test] @@ -1557,8 +1562,10 @@ async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_ response.set_cookie_headers, vec!["cf_clearance=client-ws; Domain=.chatgpt.com; Path=/".to_string()] ); - assert!(response - .rate_limit_headers - .iter() - .any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "17")); + assert!( + response + .rate_limit_headers + .iter() + .any(|(name, value)| name == "x-ratelimit-remaining-requests" && value == "17") + ); } diff --git a/backend/tests/upstream/openai/transport/websocket_pool.rs b/backend/tests/upstream/openai/transport/websocket_pool.rs index eda6cbc79..86963c97f 100644 --- a/backend/tests/upstream/openai/transport/websocket_pool.rs +++ b/backend/tests/upstream/openai/transport/websocket_pool.rs @@ -719,9 +719,11 @@ async fn websocket_pool_should_release_slot_when_client_drops_stream() { .await .expect("stream should yield an initial chunk") .expect("stream chunk should be valid"); - assert!(std::str::from_utf8(&first_chunk) - .unwrap() - .contains("streaming has begun")); + assert!( + std::str::from_utf8(&first_chunk) + .unwrap() + .contains("streaming has begun") + ); // 客户端断开:drop stream → rx 被 drop → tx.closed() 完成 → // 代理丢弃上游连接并释放 slot(不再等 idle 超时)。 @@ -927,7 +929,7 @@ async fn websocket_pool_should_gc_expired_idle_connections() { .expect("gc sweep should close the expired idle websocket") .expect("gc sweep should send a close frame") .expect("close frame should be valid"); - assert!(matches!(close, Message::Close(_))); + std::assert_matches!(close, Message::Close(_)); let (second_stream, _) = listener.accept().await.unwrap(); accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); @@ -1128,7 +1130,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_when_account_is .expect("evict_account should close the idle websocket") .expect("evict_account should send a close frame") .expect("close frame should be valid"); - assert!(matches!(close, Message::Close(_))); + std::assert_matches!(close, Message::Close(_)); let (second_stream, _) = listener.accept().await.unwrap(); accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); @@ -1233,7 +1235,7 @@ async fn codex_backend_client_should_stop_reusing_pooled_websockets_after_shutdo .expect("shutdown should close the idle websocket") .expect("shutdown should send a close frame") .expect("close frame should be valid"); - assert!(matches!(close, Message::Close(_))); + std::assert_matches!(close, Message::Close(_)); let (second_stream, _) = listener.accept().await.unwrap(); accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); @@ -1339,7 +1341,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_ .expect("liveness timeout should close the idle websocket") .expect("liveness timeout should send a close frame") .expect("close frame should be valid"); - assert!(matches!(close, Message::Close(_))); + std::assert_matches!(close, Message::Close(_)); liveness_closed_tx.send(()).unwrap(); let (second_stream, _) = listener.accept().await.unwrap(); diff --git a/deploy/Dockerfile b/deploy/Dockerfile index e79ca05a3..b1506c02f 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -21,6 +21,7 @@ ENV CPR_VERSION=${CPR_VERSION} ENV CPR_GIT_SHA=${CPR_GIT_SHA} ENV CPR_BUILD_TIME=${CPR_BUILD_TIME} ENV CPR_BUILD_TYPE=${CPR_BUILD_TYPE} +COPY .cargo/config.toml /app/.cargo/config.toml COPY backend/Cargo.toml backend/Cargo.lock ./ COPY backend/build.rs ./build.rs COPY backend/src ./src diff --git a/deploy/Dockerfile.dockerignore b/deploy/Dockerfile.dockerignore index 8f04c4391..9df521485 100644 --- a/deploy/Dockerfile.dockerignore +++ b/deploy/Dockerfile.dockerignore @@ -36,6 +36,7 @@ deploy/.env worktrees/ .codex-ws-audit/ .agents/ -.cargo/ +.cargo/* +!.cargo/config.toml .reasonix/ *.tmp diff --git a/docs/architecture.md b/docs/architecture.md index cbc23fca1..c55ff4692 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -562,7 +562,7 @@ frontend/src/ CI 的质量门禁包括: - Rust `fmt` -- Rust `clippy --all-targets --all-features -D warnings` +- Rust `clippy --all-targets --all-features --locked` - 后端集成测试 - 前端 Prettier 检查与生产构建 - GitHub Actions workflow lint diff --git a/skills/codex-proxy-rs/SKILL.md b/skills/codex-proxy-rs/SKILL.md index 1e86595dc..5039caa39 100644 --- a/skills/codex-proxy-rs/SKILL.md +++ b/skills/codex-proxy-rs/SKILL.md @@ -29,9 +29,10 @@ description: Codex Proxy RS 仓库开发指南。Use when working on its Rust/Ax 按改动范围选择足以证明行为的命令。跨领域改动执行完整门禁: ```bash -cargo fmt --manifest-path backend/Cargo.toml -- --check -cargo clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings -cargo test --manifest-path backend/Cargo.toml --test main --locked +cd backend +cargo fmt --check +cargo clippy --all-targets --all-features --locked +cargo test --test main --locked pnpm --dir frontend format:check pnpm --dir frontend build docker compose --env-file deploy/.env -f deploy/docker-compose.yml config diff --git a/skills/codex-proxy-rs/references/repo-guide.md b/skills/codex-proxy-rs/references/repo-guide.md index fe1e40da0..b163f30b6 100644 --- a/skills/codex-proxy-rs/references/repo-guide.md +++ b/skills/codex-proxy-rs/references/repo-guide.md @@ -24,7 +24,7 @@ ## 仓库结构 -- `backend/src/`:Rust 1.95 / Axum 后端生产代码。 +- `backend/src/`:Rust 1.97 / Axum 后端生产代码。 - `backend/tests/`:后端集成测试和 fixtures。测试代码禁止放进 `backend/src/`。 - `backend/build.rs`:把版本、Git SHA、构建时间和构建类型写入编译期环境变量。 - `frontend/`:Vue 3、Vite 8、Tailwind v4、Pinia、Vue Router、Axios、Lucide 和 ECharts 管理端。 @@ -73,7 +73,8 @@ 启动服务: ```bash -cargo run --manifest-path backend/Cargo.toml -- serve +cd backend +cargo run -- serve ``` `main.rs` 还提供内部维护命令 `import-sqlite` 和 `rebuild-buckets`。SQLite 导入只面向历史 schema v3,不是运行时存储路径。 @@ -110,7 +111,7 @@ cargo run --manifest-path backend/Cargo.toml -- serve - Runtime 基于 `debian:bookworm-slim`,只安装 `ca-certificates` 和健康检查所需的 `curl`。 - 镜像包含单个后端二进制和 `web/dist`,以非 root 用户 `10001:10001` 运行。 -- Builder 使用 Node 24 和 Rust 1.95,构建工具不得进入 runtime。 +- Builder 使用 Node 24 和 Rust 1.97,构建工具不得进入 runtime。 - 默认应用镜像是 `ghcr.io/zyycn/codex-proxy-rs:latest`。 `deploy/.env.example` 是 Docker 密钥模板,包含: @@ -191,9 +192,10 @@ GHCR 发布 `` 和 `sha-` tag;只有稳定版本更新 `late Rust: ```bash -cargo fmt --manifest-path backend/Cargo.toml -- --check -cargo clippy --manifest-path backend/Cargo.toml --all-targets --all-features --locked -- -D warnings -cargo test --manifest-path backend/Cargo.toml --test main --locked +cd backend +cargo fmt --check +cargo clippy --all-targets --all-features --locked +cargo test --test main --locked ``` 完整测试需要 PostgreSQL 和 Redis。通过以下变量提供连接: