diff --git a/backend/src/config/schema.rs b/backend/src/config/schema.rs index 13b11dc59..f126c2e10 100644 --- a/backend/src/config/schema.rs +++ b/backend/src/config/schema.rs @@ -100,7 +100,7 @@ impl Default for AuthConfig { refresh_concurrency: 2, max_concurrent_per_account: 3, request_interval_ms: 50, - rotation_strategy: "least_used".to_string(), + rotation_strategy: "smart".to_string(), tier_priority: Vec::new(), oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(), oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(), @@ -171,7 +171,7 @@ pub struct WebSocketPoolConfig { } fn default_ws_first_token_timeout_ms() -> u64 { - 15_000 + 20_000 } impl Default for WebSocketPoolConfig { diff --git a/backend/src/config/settings.rs b/backend/src/config/settings.rs index 5b29688d3..f0bcc5679 100644 --- a/backend/src/config/settings.rs +++ b/backend/src/config/settings.rs @@ -18,7 +18,7 @@ use crate::upstream::accounts::{ token_refresh::{RefreshPolicy, RuntimeRefreshPolicy}, }; -const ROTATION_STRATEGIES: [&str; 3] = ["least_used", "round_robin", "sticky"]; +const ROTATION_STRATEGIES: [&str; 4] = ["smart", "quota_reset_priority", "round_robin", "sticky"]; const RUNTIME_SETTINGS_ID: i64 = 1; /// 管理端可变设置。 @@ -46,7 +46,7 @@ impl Default for AdminSettings { refresh_concurrency: 2, max_concurrent_per_account: 3, request_interval_ms: 50, - rotation_strategy: "least_used".to_string(), + rotation_strategy: "smart".to_string(), } } } @@ -150,7 +150,7 @@ fn validate_rotation_strategy(strategy: &str) -> Result AccountPoolOption fn rotation_strategy_from_config(strategy: &str) -> RotationStrategy { match strategy { + "smart" => RotationStrategy::Smart, + "quota_reset_priority" => RotationStrategy::QuotaResetPriority, "round_robin" => RotationStrategy::RoundRobin, "sticky" => RotationStrategy::Sticky, - _ => RotationStrategy::LeastUsed, + _ => RotationStrategy::Smart, } } diff --git a/backend/src/infra/database.rs b/backend/src/infra/database.rs index 69d125364..7e332d3b4 100644 --- a/backend/src/infra/database.rs +++ b/backend/src/infra/database.rs @@ -4,12 +4,19 @@ use std::{fs, path::Path, str::FromStr, time::Duration}; use sqlx::{sqlite::SqliteConnectOptions, SqlitePool}; -const CURRENT_SCHEMA_VERSION: i64 = 1; -const MIGRATIONS: &[Migration] = &[Migration { - version: 1, - name: "initial", - sql: include_str!("migrations/0001_initial.sql"), -}]; +const CURRENT_SCHEMA_VERSION: i64 = 2; +const MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "initial", + sql: include_str!("migrations/0001_initial.sql"), + }, + Migration { + version: 2, + name: "rename_rotation_strategies", + sql: include_str!("migrations/0002_rename_rotation_strategies.sql"), + }, +]; struct Migration { version: i64, diff --git a/backend/src/infra/migrations/0001_initial.sql b/backend/src/infra/migrations/0001_initial.sql index 84f716202..68fbce922 100644 --- a/backend/src/infra/migrations/0001_initial.sql +++ b/backend/src/infra/migrations/0001_initial.sql @@ -49,7 +49,7 @@ create table runtime_settings ( refresh_concurrency integer not null check (refresh_concurrency > 0), max_concurrent_per_account integer not null check (max_concurrent_per_account > 0), request_interval_ms integer not null check (request_interval_ms >= 0), - rotation_strategy text not null check (rotation_strategy in ('least_used', 'round_robin', 'sticky')), + rotation_strategy text not null check (rotation_strategy in ('smart', 'quota_reset_priority', 'round_robin', 'sticky')), admin_api_key text, updated_at text not null ); diff --git a/backend/src/infra/migrations/0002_rename_rotation_strategies.sql b/backend/src/infra/migrations/0002_rename_rotation_strategies.sql new file mode 100644 index 000000000..a18de3c42 --- /dev/null +++ b/backend/src/infra/migrations/0002_rename_rotation_strategies.sql @@ -0,0 +1,40 @@ +create table runtime_settings_new ( + id integer primary key check (id = 1), + model_aliases_json text not null default '{}', + refresh_margin_seconds integer not null check (refresh_margin_seconds > 0), + refresh_concurrency integer not null check (refresh_concurrency > 0), + max_concurrent_per_account integer not null check (max_concurrent_per_account > 0), + request_interval_ms integer not null check (request_interval_ms >= 0), + rotation_strategy text not null check (rotation_strategy in ('smart', 'quota_reset_priority', 'round_robin', 'sticky')), + admin_api_key text, + updated_at text not null +); + +insert into runtime_settings_new ( + id, + model_aliases_json, + refresh_margin_seconds, + refresh_concurrency, + max_concurrent_per_account, + request_interval_ms, + rotation_strategy, + admin_api_key, + updated_at +) +select + id, + model_aliases_json, + refresh_margin_seconds, + refresh_concurrency, + max_concurrent_per_account, + request_interval_ms, + case rotation_strategy + when 'least_used' then 'quota_reset_priority' + else rotation_strategy + end, + admin_api_key, + updated_at +from runtime_settings; + +drop table runtime_settings; +alter table runtime_settings_new rename to runtime_settings; diff --git a/backend/src/upstream/accounts/pool.rs b/backend/src/upstream/accounts/pool.rs index be1d1ee3e..d25cd3101 100644 --- a/backend/src/upstream/accounts/pool.rs +++ b/backend/src/upstream/accounts/pool.rs @@ -27,8 +27,10 @@ use crate::upstream::protocol::events::{ /// 账号轮转策略。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RotationStrategy { - /// 优先选择当前窗口用量最低的账号。 - LeastUsed, + /// 综合并发压力、窗口用量和历史用量进行调度。 + Smart, + /// 优先消耗即将重置的额度窗口。 + QuotaResetPriority, /// 按候选账号顺序循环选择。 RoundRobin, /// 优先选择最近使用过的账号。 @@ -38,7 +40,8 @@ pub enum RotationStrategy { impl RotationStrategy { fn as_str(self) -> &'static str { match self { - Self::LeastUsed => "least_used", + Self::Smart => "smart", + Self::QuotaResetPriority => "quota_reset_priority", Self::RoundRobin => "round_robin", Self::Sticky => "sticky", } @@ -69,7 +72,7 @@ impl Default for AccountPoolOptions { Self { max_concurrent_per_account: 3, stale_slot_ttl: Duration::minutes(5), - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::Smart, skip_quota_limited: true, tier_priority: Vec::new(), model_plan_allowlist: BTreeMap::new(), @@ -275,6 +278,16 @@ struct AccountSlot { model: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct SmartAccountLoadScore { + quota_limited: bool, + slot_count: usize, + window_request_count: u64, + window_token_count: u64, + window_cached_tokens: u64, + request_count: u64, +} + /// 账号释放结果。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReleasedAccountUsage { @@ -511,10 +524,15 @@ impl AccountPool { } .or_else(|| { Some(match self.options.rotation_strategy { - RotationStrategy::LeastUsed => { - let account = self.select_least_used(&candidates)?; + RotationStrategy::Smart => { + let account = self.select_smart(&candidates)?; let previous = self.previous_slot_at(&account.id); - (account, previous, "least_used") + (account, previous, "smart") + } + RotationStrategy::QuotaResetPriority => { + let account = self.select_quota_reset_priority(&candidates)?; + let previous = self.previous_slot_at(&account.id); + (account, previous, "quota_reset_priority") } RotationStrategy::RoundRobin => { let account = self.select_round_robin(&candidates)?; @@ -534,19 +552,25 @@ impl AccountPool { refreshed_accounts, }; }; + let selected_slot_count = self.slot_count(&selected.id); + let selected_window_token_count = account_window_token_count(&selected); + let selected_smart_load_score = self.smart_load_score(&selected); tracing::info!( model = %request.model, rotation_strategy = self.options.rotation_strategy.as_str(), selection_source, account_id = %selected.id, candidate_count = candidates.len(), + slot_count = selected_slot_count, request_count = selected.request_count, window_request_count = selected.window_request_count, + window_token_count = selected_window_token_count, window_reset_at = ?selected.window_reset_at, last_used_at = selected.last_used_at.as_deref().unwrap_or_default(), quota_limit_reached = selected.quota_limit_reached, quota_cooldown_until = ?selected.quota_cooldown_until, previous_slot_at = ?previous_slot_at, + smart_load_score = ?selected_smart_load_score, rotation_cursor = self.rotation_cursor, "account selected for upstream request" ); @@ -681,24 +705,31 @@ impl AccountPool { let mut accounts = Vec::new(); for (plan_type, group) in by_plan { let selected = match self.options.rotation_strategy { - RotationStrategy::LeastUsed => self.select_least_used(&group), + RotationStrategy::Smart => self.select_smart(&group), + RotationStrategy::QuotaResetPriority => self.select_quota_reset_priority(&group), RotationStrategy::RoundRobin => self.select_round_robin(&group), RotationStrategy::Sticky => Self::select_sticky(&group), }; let Some(account) = selected else { continue; }; + let selected_slot_count = self.slot_count(&account.id); + let selected_window_token_count = account_window_token_count(&account); + let selected_smart_load_score = self.smart_load_score(&account); tracing::info!( plan_type, rotation_strategy = self.options.rotation_strategy.as_str(), account_id = %account.id, candidate_count = group.len(), + slot_count = selected_slot_count, request_count = account.request_count, window_request_count = account.window_request_count, + window_token_count = selected_window_token_count, window_reset_at = ?account.window_reset_at, last_used_at = account.last_used_at.as_deref().unwrap_or_default(), quota_limit_reached = account.quota_limit_reached, quota_cooldown_until = ?account.quota_cooldown_until, + smart_load_score = ?selected_smart_load_score, rotation_cursor = self.rotation_cursor, "account selected for model refresh" ); @@ -712,19 +743,49 @@ impl AccountPool { } } - fn select_least_used(&mut self, candidates: &[Account]) -> Option { + fn select_smart(&mut self, candidates: &[Account]) -> Option { + self.select_by(candidates, |pool, a, b| pool.compare_smart(a, b)) + } + + fn select_quota_reset_priority(&mut self, candidates: &[Account]) -> Option { + self.select_by(candidates, |_pool, a, b| compare_quota_reset_priority(a, b)) + } + + fn select_by( + &mut self, + candidates: &[Account], + compare: impl Fn(&Self, &Account, &Account) -> Ordering, + ) -> Option { let mut sorted = candidates.iter().collect::>(); - sorted.sort_by(|a, b| compare_least_used(a, b)); + sorted.sort_by(|a, b| compare(self, a, b)); let best = *sorted.first()?; let tied_count = sorted .iter() - .take_while(|account| compare_least_used(best, account) == Ordering::Equal) + .take_while(|account| compare(self, best, account) == Ordering::Equal) .count(); let index = self.rotation_cursor % tied_count; self.rotation_cursor = self.rotation_cursor.wrapping_add(1); Some((*sorted[index]).clone()) } + fn compare_smart(&self, a: &Account, b: &Account) -> Ordering { + self.smart_load_score(a) + .cmp(&self.smart_load_score(b)) + .then_with(|| compare_window_reset(a.window_reset_at, b.window_reset_at)) + .then_with(|| compare_last_used(a.last_used_at.as_deref(), b.last_used_at.as_deref())) + } + + fn smart_load_score(&self, account: &Account) -> SmartAccountLoadScore { + SmartAccountLoadScore { + quota_limited: account.quota_limit_reached, + slot_count: self.slot_count(&account.id), + window_request_count: account.window_request_count, + window_token_count: account_window_token_count(account), + window_cached_tokens: account.window_cached_tokens, + request_count: account.request_count, + } + } + fn select_sticky(candidates: &[Account]) -> Option { let mut selected = candidates.first()?; for candidate in &candidates[1..] { @@ -1460,7 +1521,7 @@ fn refresh_cloudflare_cooldown(account: &mut Account, now: DateTime) { } } -fn compare_least_used(a: &Account, b: &Account) -> Ordering { +fn compare_quota_reset_priority(a: &Account, b: &Account) -> Ordering { a.quota_limit_reached .cmp(&b.quota_limit_reached) .then_with(|| compare_window_reset(a.window_reset_at, b.window_reset_at)) @@ -1468,6 +1529,14 @@ fn compare_least_used(a: &Account, b: &Account) -> Ordering { .then_with(|| compare_last_used(a.last_used_at.as_deref(), b.last_used_at.as_deref())) } +fn account_window_token_count(account: &Account) -> u64 { + account + .window_input_tokens + .saturating_add(account.window_output_tokens) + .saturating_add(account.window_image_input_tokens) + .saturating_add(account.window_image_output_tokens) +} + fn compare_window_reset(a: Option>, b: Option>) -> Ordering { match (a, b) { (Some(a), Some(b)) => a.cmp(&b), diff --git a/backend/src/upstream/transport/websocket_pool.rs b/backend/src/upstream/transport/websocket_pool.rs index 38f16ba5c..59544d039 100644 --- a/backend/src/upstream/transport/websocket_pool.rs +++ b/backend/src/upstream/transport/websocket_pool.rs @@ -17,7 +17,7 @@ const DEFAULT_MAX_AGE: Duration = Duration::from_mins(55); const DEFAULT_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(25); const DEFAULT_PING_INTERVAL: Duration = Duration::from_secs(25); const DEFAULT_PING_TIMEOUT: Duration = Duration::from_secs(5); -pub(crate) const DEFAULT_FIRST_TOKEN_TIMEOUT: Duration = Duration::from_secs(15); +pub(crate) const DEFAULT_FIRST_TOKEN_TIMEOUT: Duration = Duration::from_secs(20); /// WebSocket 连接池 key。 #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/backend/tests/admin/settings/mod.rs b/backend/tests/admin/settings/mod.rs index adc237268..daac741e7 100644 --- a/backend/tests/admin/settings/mod.rs +++ b/backend/tests/admin/settings/mod.rs @@ -7,7 +7,7 @@ fn settings_service_should_apply_retained_settings_patch() { let mut current = AdminSettings { max_concurrent_per_account: 4, request_interval_ms: 50, - rotation_strategy: "least_used".to_string(), + rotation_strategy: "smart".to_string(), ..AdminSettings::default() }; diff --git a/backend/tests/admin/settings/routes.rs b/backend/tests/admin/settings/routes.rs index 34834bb5f..4a6db9b7a 100644 --- a/backend/tests/admin/settings/routes.rs +++ b/backend/tests/admin/settings/routes.rs @@ -69,7 +69,7 @@ async fn admin_settings_should_return_runtime_fields() { assert_eq!(body["data"]["modelAliases"]["codex-fast"], "gpt-5.5"); assert_eq!(body["data"]["refreshMarginSeconds"], 240); assert_eq!(body["data"]["refreshConcurrency"], 2); - assert_eq!(body["data"]["rotationStrategy"], "least_used"); + assert_eq!(body["data"]["rotationStrategy"], "smart"); } #[tokio::test] diff --git a/backend/tests/config/mod.rs b/backend/tests/config/mod.rs index 3000fa683..fa27c7bc6 100644 --- a/backend/tests/config/mod.rs +++ b/backend/tests/config/mod.rs @@ -84,7 +84,7 @@ fn default_config_keeps_only_codex_backend() { assert_eq!(cfg.api.base_url, "https://chatgpt.com/backend-api"); assert!(cfg.model_aliases.is_empty()); assert_eq!(cfg.auth.refresh_margin_seconds, 300); - assert_eq!(cfg.auth.rotation_strategy, "least_used"); + assert_eq!(cfg.auth.rotation_strategy, "smart"); assert_eq!(cfg.auth.oauth_client_id, "app_EMoamEEZ73f0CkXaXp7hrann"); assert_eq!( cfg.auth.oauth_token_endpoint, diff --git a/backend/tests/infra/storage_schema/mod.rs b/backend/tests/infra/storage_schema/mod.rs index e3c8b09d0..09d409ccb 100644 --- a/backend/tests/infra/storage_schema/mod.rs +++ b/backend/tests/infra/storage_schema/mod.rs @@ -51,7 +51,13 @@ async fn sqlite_schema_should_record_applied_migrations() { .await .unwrap(); - assert_eq!(rows, [(1, "initial".to_string())]); + assert_eq!( + rows, + [ + (1, "initial".to_string()), + (2, "rename_rotation_strategies".to_string()), + ] + ); } #[test] @@ -536,12 +542,12 @@ async fn sqlite_schema_should_reject_invalid_runtime_settings() { let pool = connect_sqlite(&url).await.unwrap(); let invalid_id_result = sqlx::query( - "insert into runtime_settings (id, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (2, 300, 2, 3, 50, 'least_used', '2026-06-14T00:00:00Z')", + "insert into runtime_settings (id, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (2, 300, 2, 3, 50, 'smart', '2026-06-14T00:00:00Z')", ) .execute(&pool) .await; let invalid_refresh_result = sqlx::query( - "insert into runtime_settings (id, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (1, 0, 2, 3, 50, 'least_used', '2026-06-14T00:00:00Z')", + "insert into runtime_settings (id, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at) values (1, 0, 2, 3, 50, 'smart', '2026-06-14T00:00:00Z')", ) .execute(&pool) .await; diff --git a/backend/tests/support/config.rs b/backend/tests/support/config.rs index 472a6624a..b95ba37ad 100644 --- a/backend/tests/support/config.rs +++ b/backend/tests/support/config.rs @@ -20,7 +20,7 @@ pub(crate) fn test_config(database_url: String) -> AppConfig { refresh_concurrency: 2, max_concurrent_per_account: 3, request_interval_ms: 50, - rotation_strategy: "least_used".to_string(), + rotation_strategy: "smart".to_string(), tier_priority: Vec::new(), oauth_client_id: "app_EMoamEEZ73f0CkXaXp7hrann".to_string(), oauth_token_endpoint: "https://auth.openai.com/oauth/token".to_string(), diff --git a/backend/tests/upstream/accounts/account_pool/quota.rs b/backend/tests/upstream/accounts/account_pool/quota.rs index ac8d4fd21..cb56d3694 100644 --- a/backend/tests/upstream/accounts/account_pool/quota.rs +++ b/backend/tests/upstream/accounts/account_pool/quota.rs @@ -153,10 +153,10 @@ fn account_pool_should_skip_accounts_in_cloudflare_cooldown() { } #[test] -fn least_used_should_deprioritize_quota_limited_accounts_when_skip_is_disabled() { +fn quota_reset_priority_should_deprioritize_quota_limited_accounts_when_skip_is_disabled() { let mut pool = AccountPool::with_options(AccountPoolOptions { skip_quota_limited: false, - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); let mut limited = crate::support::accounts::test_account("limited", AccountStatus::Active); @@ -172,10 +172,10 @@ fn least_used_should_deprioritize_quota_limited_accounts_when_skip_is_disabled() } #[test] -fn least_used_should_prefer_earlier_rate_limit_window_reset() { +fn quota_reset_priority_should_prefer_earlier_rate_limit_window_reset() { let now = fixed_time(); let mut pool = AccountPool::with_options(AccountPoolOptions { - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); let mut soon = crate::support::accounts::test_account("soon", AccountStatus::Active); diff --git a/backend/tests/upstream/accounts/account_pool/selection.rs b/backend/tests/upstream/accounts/account_pool/selection.rs index d81febd84..7e6e9af42 100644 --- a/backend/tests/upstream/accounts/account_pool/selection.rs +++ b/backend/tests/upstream/accounts/account_pool/selection.rs @@ -543,10 +543,244 @@ fn account_pool_should_cleanup_stale_slots_before_acquire() { } #[test] -fn account_pool_should_rotate_tied_least_used_accounts() { +fn smart_should_spread_concurrent_requests_by_slot_pressure() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + max_concurrent_per_account: 3, + rotation_strategy: RotationStrategy::Smart, + ..AccountPoolOptions::default() + }); + pool.insert(crate::support::accounts::test_account( + "acct_a", + AccountStatus::Active, + )); + pool.insert(crate::support::accounts::test_account( + "acct_b", + AccountStatus::Active, + )); + pool.insert(crate::support::accounts::test_account( + "acct_c", + AccountStatus::Active, + )); + + let first = pool + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + let second = pool + .acquire_with(&AccountAcquireRequest::new( + "gpt-5.5", + now + Duration::milliseconds(1), + )) + .unwrap(); + let third = pool + .acquire_with(&AccountAcquireRequest::new( + "gpt-5.5", + now + Duration::milliseconds(2), + )) + .unwrap(); + + assert_eq!( + [first.account.id, second.account.id, third.account.id] + .into_iter() + .collect::>() + .len(), + 3 + ); +} + +#[test] +fn smart_should_balance_released_requests_by_runtime_usage() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + rotation_strategy: RotationStrategy::Smart, + ..AccountPoolOptions::default() + }); + pool.insert(crate::support::accounts::test_account( + "acct_a", + AccountStatus::Active, + )); + pool.insert(crate::support::accounts::test_account( + "acct_b", + AccountStatus::Active, + )); + + let first = pool + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + pool.release(&first.account.id); + let second = pool + .acquire_with(&AccountAcquireRequest::new( + "gpt-5.5", + now + Duration::seconds(1), + )) + .unwrap(); + + assert_ne!(first.account.id, second.account.id); +} + +#[test] +fn smart_should_not_let_reset_window_override_lower_request_count() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + rotation_strategy: RotationStrategy::Smart, + ..AccountPoolOptions::default() + }); + let mut reset_soon = + crate::support::accounts::test_account("reset_soon", AccountStatus::Active); + reset_soon.request_count = 10; + reset_soon.window_request_count = 10; + reset_soon.window_reset_at = Some(now + Duration::seconds(30)); + let mut quieter = crate::support::accounts::test_account("quieter", AccountStatus::Active); + quieter.request_count = 2; + quieter.window_request_count = 2; + quieter.window_reset_at = Some(now + Duration::seconds(300)); + pool.insert(reset_soon); + pool.insert(quieter); + + let acquired = pool + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.id, "quieter"); +} + +#[test] +fn smart_should_not_drain_earliest_reset_account_when_other_accounts_are_idle() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + rotation_strategy: RotationStrategy::Smart, + ..AccountPoolOptions::default() + }); + let mut reset_first = + crate::support::accounts::test_account("reset_first", AccountStatus::Active); + reset_first.window_reset_at = Some(now + Duration::seconds(30)); + let mut reset_later = + crate::support::accounts::test_account("reset_later", AccountStatus::Active); + reset_later.window_reset_at = Some(now + Duration::minutes(10)); + let mut reset_latest = + crate::support::accounts::test_account("reset_latest", AccountStatus::Active); + reset_latest.window_reset_at = Some(now + Duration::minutes(20)); + pool.insert(reset_first); + pool.insert(reset_later); + pool.insert(reset_latest); + + let mut selected_ids = Vec::new(); + for offset in 0..4 { + let acquired = pool + .acquire_with(&AccountAcquireRequest::new( + "gpt-5.5", + now + Duration::seconds(offset), + )) + .unwrap(); + selected_ids.push(acquired.account.id.clone()); + pool.release(&acquired.account.id); + } + + assert_eq!( + selected_ids + .iter() + .take(3) + .cloned() + .collect::>() + .len(), + 3 + ); + assert!(selected_ids.windows(2).all(|pair| pair[0] != pair[1])); +} + +#[test] +fn smart_should_use_window_token_load_when_request_counts_tie() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + rotation_strategy: RotationStrategy::Smart, + ..AccountPoolOptions::default() + }); + let mut heavy = crate::support::accounts::test_account("heavy", AccountStatus::Active); + heavy.request_count = 5; + heavy.window_request_count = 3; + heavy.window_input_tokens = 1_000_000; + heavy.window_output_tokens = 500_000; + let mut light = crate::support::accounts::test_account("light", AccountStatus::Active); + light.request_count = 5; + light.window_request_count = 3; + light.window_input_tokens = 8_000; + light.window_output_tokens = 2_000; + pool.insert(heavy); + pool.insert(light); + + let acquired = pool + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.id, "light"); +} + +#[test] +fn smart_should_not_double_count_cached_tokens_when_request_counts_tie() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + rotation_strategy: RotationStrategy::Smart, + ..AccountPoolOptions::default() + }); + let mut cache_heavy = + crate::support::accounts::test_account("cache_heavy", AccountStatus::Active); + cache_heavy.request_count = 5; + cache_heavy.window_request_count = 3; + cache_heavy.window_input_tokens = 10_000; + cache_heavy.window_output_tokens = 1_000; + cache_heavy.window_cached_tokens = 100_000; + let mut heavier_actual = + crate::support::accounts::test_account("heavier_actual", AccountStatus::Active); + heavier_actual.request_count = 5; + heavier_actual.window_request_count = 3; + heavier_actual.window_input_tokens = 20_000; + heavier_actual.window_output_tokens = 1_000; + pool.insert(cache_heavy); + pool.insert(heavier_actual); + + let acquired = pool + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.id, "cache_heavy"); +} + +#[test] +fn smart_should_use_cached_tokens_as_tie_break_when_actual_token_load_ties() { + let now = fixed_time(); + let mut pool = AccountPool::with_options(AccountPoolOptions { + rotation_strategy: RotationStrategy::Smart, + ..AccountPoolOptions::default() + }); + let mut cache_heavy = + crate::support::accounts::test_account("cache_heavy", AccountStatus::Active); + cache_heavy.request_count = 5; + cache_heavy.window_request_count = 3; + cache_heavy.window_input_tokens = 10_000; + cache_heavy.window_output_tokens = 1_000; + cache_heavy.window_cached_tokens = 100_000; + let mut cache_light = + crate::support::accounts::test_account("cache_light", AccountStatus::Active); + cache_light.request_count = 5; + cache_light.window_request_count = 3; + cache_light.window_input_tokens = 10_000; + cache_light.window_output_tokens = 1_000; + cache_light.window_cached_tokens = 1_000; + pool.insert(cache_heavy); + pool.insert(cache_light); + + let acquired = pool + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", now)) + .unwrap(); + + assert_eq!(acquired.account.id, "cache_light"); +} + +#[test] +fn account_pool_should_rotate_tied_quota_reset_priority_accounts() { let mut pool = AccountPool::with_options(AccountPoolOptions { max_concurrent_per_account: 1, - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); pool.insert(crate::support::accounts::test_account( @@ -566,11 +800,11 @@ fn account_pool_should_rotate_tied_least_used_accounts() { } #[test] -fn least_used_should_prefer_lru_before_tie_rotation() { +fn quota_reset_priority_should_prefer_lru_before_tie_rotation() { let now = fixed_time(); let mut pool = AccountPool::with_options(AccountPoolOptions { max_concurrent_per_account: 1, - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); pool.insert(crate::support::accounts::test_account( @@ -603,10 +837,10 @@ fn least_used_should_prefer_lru_before_tie_rotation() { } #[test] -fn least_used_should_prefer_lower_runtime_request_count() { +fn quota_reset_priority_should_prefer_lower_runtime_request_count() { let now = fixed_time(); let mut pool = AccountPool::with_options(AccountPoolOptions { - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); let mut busy = crate::support::accounts::test_account("busy", AccountStatus::Active); @@ -624,10 +858,10 @@ fn least_used_should_prefer_lower_runtime_request_count() { } #[test] -fn least_used_should_not_penalize_missing_window_reset() { +fn quota_reset_priority_should_not_penalize_missing_window_reset() { let now = fixed_time(); let mut pool = AccountPool::with_options(AccountPoolOptions { - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); let mut fresh = crate::support::accounts::test_account("fresh", AccountStatus::Active); @@ -647,10 +881,10 @@ fn least_used_should_not_penalize_missing_window_reset() { } #[test] -fn least_used_should_fall_through_to_request_count_without_window_resets() { +fn quota_reset_priority_should_fall_through_to_request_count_without_window_resets() { let now = fixed_time(); let mut pool = AccountPool::with_options(AccountPoolOptions { - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); let mut busier = crate::support::accounts::test_account("busier", AccountStatus::Active); @@ -668,11 +902,11 @@ fn least_used_should_fall_through_to_request_count_without_window_resets() { } #[test] -fn least_used_should_sort_quota_limited_accounts_by_reset_when_not_skipping() { +fn quota_reset_priority_should_sort_quota_limited_accounts_by_reset_when_not_skipping() { let now = fixed_time(); let mut pool = AccountPool::with_options(AccountPoolOptions { skip_quota_limited: false, - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); let mut later = crate::support::accounts::test_account("later", AccountStatus::Active); @@ -692,10 +926,10 @@ fn least_used_should_sort_quota_limited_accounts_by_reset_when_not_skipping() { } #[test] -fn least_used_should_not_mutate_candidate_order() { +fn quota_reset_priority_should_not_mutate_candidate_order() { let now = fixed_time(); let mut pool = AccountPool::with_options(AccountPoolOptions { - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); let mut first = crate::support::accounts::test_account("first", AccountStatus::Active); diff --git a/backend/tests/upstream/accounts/account_pool/usage_window.rs b/backend/tests/upstream/accounts/account_pool/usage_window.rs index c4a97513b..20f0ffe38 100644 --- a/backend/tests/upstream/accounts/account_pool/usage_window.rs +++ b/backend/tests/upstream/accounts/account_pool/usage_window.rs @@ -68,10 +68,10 @@ fn capacity_summary_should_not_exclude_cloudflare_cooldown_accounts() { } #[test] -fn least_used_should_compare_request_count_when_only_one_account_has_window_reset() { +fn quota_reset_priority_should_compare_request_count_when_only_one_account_has_window_reset() { let now = fixed_time(); let mut pool = AccountPool::with_options(AccountPoolOptions { - rotation_strategy: RotationStrategy::LeastUsed, + rotation_strategy: RotationStrategy::QuotaResetPriority, ..AccountPoolOptions::default() }); let mut reset_known = diff --git a/docs/architecture.md b/docs/architecture.md index a6e9fa100..386a199f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,85 +2,106 @@ ## 总览 -Codex Proxy RS 是单进程应用。Rust 后端同时处理 Responses 兼容代理、管理端 API、SQLite 持久化、后台任务、静态前端托管和在线更新;Vue 管理端作为 SPA 由同一个进程托管。 +Codex Proxy RS 是一个单进程代理和管理系统。Rust 后端同时承载 OpenAI Responses 兼容代理、管理端 API、SQLite 持久化、后台任务、静态前端托管和在线更新;Vue 管理端作为 SPA 由同一个进程托管。 ```mermaid flowchart LR Client["客户端 / OpenAI SDK"] -->|"Authorization: Bearer
/v1/responses"| Http["Axum Router"] - Browser["浏览器"] -->|"管理端 SPA"| Web["web/dist"] - Browser -->|"/api/admin/*"| Http + Browser["浏览器"] -->|"管理端 SPA
/api/admin/*"| Http subgraph App["codex-proxy-rs"] - Http --> Proxy["proxy
Responses / Models 入口
客户端 API Key 鉴权"] - Http --> Admin["admin
账号 / Key / 设置 / 监控 / 系统更新"] - Http --> Web + Http --> Proxy["proxy
OpenAI compatible routes"] + Http --> Admin["admin
accounts / keys / monitoring / system"] + Http --> Web["web
static SPA"] - Proxy --> Dispatch["dispatch
账号选择
限流 / quota / 亲和性 / 回退"] - Dispatch --> Protocol["upstream protocol
请求转换
SSE / 错误映射"] + Proxy --> Dispatch["dispatch
account selection / affinity / retry"] + Dispatch --> Protocol["upstream protocol
request / SSE / error mapping"] Protocol --> Transport["upstream transport
HTTP / SSE / WebSocket"] - Admin --> Runtime["runtime services
账号池 / 后台任务 / 更新状态"] - Dispatch --> Runtime - Runtime --> Store[("SQLite
账号 / Key / session / 用量 / 设置")] - Admin --> Store + Admin --> Services["runtime services"] + Dispatch --> Services + Services --> Store[("SQLite")] end - Transport --> Upstream["ChatGPT / Codex Upstream"] - Release["GitHub Release
archives + checksums"] --> Updater["self update
下载 / 校验 / 替换 / 重启"] - Updater --> Admin - GHCR["GHCR image
linux/amd64 + linux/arm64"] --> Docker["Docker Compose
.runtime bind mounts"] + Transport --> Upstream["ChatGPT / Codex backend"] + Services --> Tasks["background tasks"] + Tasks --> Store + Updater["self update"] --> Store ``` +核心原则: + +- 启动配置只负责进程启动必需项。 +- 账号、Key、模型别名和运行参数保存在 SQLite,并通过管理端热更新。 +- 代理热路径只依赖运行时内存服务,持久化写入在请求完成、后台任务或设置更新时同步。 +- 上游请求统一经过账号池、session affinity、quota、fingerprint 和 transport 层。 + ## 仓库边界 - `backend/`:Rust/Axum 后端、SQLite migration、构建脚本和集成测试。 -- `frontend/`:Vue 3 管理端,使用 Vite 8、Tailwind v4、Pinia、Vue Router、ECharts 和 lucide 图标。 -- `deploy/`:Dockerfile、Compose 和 Docker 部署配置模板。 -- `docs/`:长期维护文档,记录当前有效结构与决策。 +- `frontend/`:Vue 3 管理端,使用 Vite、Pinia、Vue Router、ECharts 和 lucide 图标。 +- `deploy/`:Dockerfile、Compose 和部署配置模板。 +- `docs/`:长期维护文档。 - `release/`:版本号、平台矩阵和发布脚本。 - `skills/`:项目本地 Codex skill。 -当前后端是 `backend/` 单 crate。历史迁移布局不再作为当前架构的一部分记录。 +测试统一放在 `backend/tests/`,不在 `backend/src/` 新增测试模块。 ## 后端结构 -入口: +入口和组合层: - `backend/src/main.rs`:二进制入口。 -- `backend/src/lib.rs`:集成测试和二进制共享的库入口。 -- `backend/src/runtime/bootstrap.rs`:加载配置、初始化日志、连接数据库、构造服务、启动 HTTP。 -- `backend/src/runtime/services.rs`:集中构造账号、模型、监控、更新等运行时服务。 +- `backend/src/lib.rs`:二进制和集成测试共享入口。 +- `runtime/bootstrap.rs`:加载配置、初始化日志、连接 SQLite、构造服务、恢复运行时状态并启动 HTTP。 +- `runtime/services.rs`:集中构造账号、模型、监控、更新、fingerprint、WebSocket 池等服务。 +- `runtime/tasks/`:后台任务协调和周期任务。 -主要模块: +主要业务模块: -- `admin/`:管理端 API,包括登录 session、账号、API Key、监控、运行设置和系统更新。 -- `proxy/`:Responses/Models HTTP 入口、客户端 API Key 鉴权、请求分派。 -- `proxy/openai/`:Responses 和 Models 的协议入口。 -- `proxy/dispatch/`:账号选择、quota/限流处理、错误映射、session affinity 和失败回退。 -- `upstream/`:ChatGPT/Codex 上游账号、模型、协议、传输、token 刷新和 fingerprint。 -- `config/`:启动配置、运行时设置和管理端设置落库。 -- `infra/`:SQLite、migration、日志、时间、格式化、JSON 和身份哈希。 +- `admin/`:管理端 API,包括登录 session、账号、API Key、监控、设置和系统更新。 +- `proxy/`:OpenAI 兼容路由、客户端 API Key 鉴权和请求分派。 +- `proxy/openai/`:Responses、review、compact、Models 的 HTTP 入口。 +- `proxy/dispatch/`:账号选择、session affinity、quota 验证、历史恢复、错误映射、用量记录。 +- `upstream/accounts/`:账号模型、账号池、导入、Cookie、quota、token refresh、SQLite store。 +- `upstream/models/`:模型目录、模型别名、模型计划快照。 +- `upstream/protocol/`:Responses 请求、SSE、WebSocket 事件和错误转换。 +- `upstream/transport/`:Reqwest、TLS、WebSocket、WebSocket pool、fingerprint 请求头。 +- `config/`:启动配置 schema、运行时设置和数据库设置加载。 +- `infra/`:SQLite、migration、日志、时间、JSON、格式化和身份哈希。 - `http/`:router、中间件、可信代理和请求上下文。 - `web/`:前端静态资源和 SPA fallback。 -测试统一放在 `backend/tests/`,不在 `backend/src/` 新增测试模块。 - -## 配置和运行时目录 +## 配置 启动配置由 `CPR_CONFIG_FILE` 指定;未指定时读取当前工作目录的 `config.yaml`。 -配置文件只负责启动必需项: +配置文件字段: -- HTTP 监听。 -- 上游 base URL。 -- SQLite URL。 -- 日志目录。 -- 管理员首次初始化密码。 -- TLS、WebSocket 池、quota 周期和 fingerprint 默认值。 +- `server`:监听地址、端口、可信代理。 +- `api`:上游 base URL。 +- `database`:SQLite URL。 +- `tls`:HTTP/TLS 偏好。 +- `ws_pool`:WebSocket 连接池和首内容帧超时。 +- `quota`:quota 刷新周期和是否跳过耗尽账号。 +- `fingerprint`:默认 Codex Desktop 指纹和请求头。 +- `admin`:管理员首次初始化和 session 清理配置。 +- `logging`:日志目录、保留期和使用记录开关。 -账号、客户端 API Key、模型别名、账号选择策略和多数运行参数由管理端写入 SQLite。 +运行时设置保存在 `runtime_settings`: -`.runtime` 是本项目统一运行目录: +- `modelAliases` +- `refreshMarginSeconds` +- `refreshConcurrency` +- `maxConcurrentPerAccount` +- `requestIntervalMs` +- `rotationStrategy` + +这些设置由管理端更新,并同步到账号池、token refresh 策略和模型服务。 + +## 运行目录 + +`.runtime` 是默认运行目录: ```text .runtime/config.yaml @@ -91,7 +112,7 @@ flowchart LR .runtime/logs/ ``` -Docker 也使用宿主机 `.runtime`,但容器内配置路径仍然写 `/app/data` 和 `/app/logs`。Compose 默认映射: +Docker 默认映射: ```text .runtime/config.yaml -> /app/config.yaml @@ -99,27 +120,227 @@ Docker 也使用宿主机 `.runtime`,但容器内配置路径仍然写 `/app/d .runtime/logs -> /app/logs ``` -容器以非 root 用户 `10001:10001` 运行。Compose 设置 `HOME=/app` 和 `XDG_DATA_HOME=/app/data`,确保 installation id、SQLite、更新状态和日志都写入可持久化目录。 +容器以非 root 用户 `10001:10001` 运行。Compose 设置 `HOME=/app` 和 `XDG_DATA_HOME=/app/data`,确保 installation id、SQLite、更新状态和日志写入持久化目录。 -## 代理请求链路 +## SQLite -客户端请求进入 `/v1/responses`、`/v1/responses/review`、`/v1/responses/compact` 或 `/v1/models*`: +当前 schema 由 `backend/src/infra/migrations` 管理。 -1. `proxy/auth` 校验客户端 API Key,并记录调用上下文。 -2. `proxy/openai` 解析 Responses 或 Models 请求。 -3. `proxy/dispatch` 按模型、quota、账号状态和选择策略挑选账号。 -4. `upstream/protocol` 转换为 ChatGPT/Codex 上游协议。 -5. `upstream/transport` 使用 HTTP、SSE 或 WebSocket 访问上游。 -6. 响应和错误被转换回 OpenAI Responses/Models 兼容格式。 -7. 用量、模型维度统计、请求记录、账号窗口和失败状态写回 SQLite/账号池。 +核心表: -账号状态由 SQLite 恢复到内存账号池,并在请求、token 刷新、quota 刷新、Cloudflare 处理和失败回退时同步更新。 +- `admin_users`、`admin_sessions`:管理端账号和 session。 +- `client_api_keys`:客户端 API Key。 +- `runtime_settings`:管理端运行设置。 +- `accounts`:账号身份、token、状态、quota、fingerprint 关联信息。 +- `account_usage`:账号累计用量和当前 quota 窗口用量。 +- `account_model_usage`:账号和模型维度的请求、错误和 token 用量。 +- `account_refresh_leases`:token refresh 分布式租约。 +- `account_cookies`:账号 Cookie。 +- `fingerprints`、`fingerprint_update_history`:当前指纹和更新历史。 +- `usage_records`:网关和上游事件明细。 +- `usage_time_buckets`:15 分钟聚合桶,供 Dashboard 和趋势接口使用。 +- `model_plan_snapshots`:模型到账号计划的可用性快照。 +- `session_affinities`:response ID、conversation、账号、turn state 和 function call 关联。 -## 管理端 +## 代理入口 -管理端 API 位于 `/api/admin/*`,使用 session cookie 保护。前端由后端托管,不单独部署 Node 服务。 +客户端请求入口: -前端结构: +- `POST /v1/responses` +- `POST /v1/responses/review` +- `POST /v1/responses/compact` +- `GET /v1/models` +- `GET /v1/models/{model}` + +请求链路: + +1. HTTP 中间件生成 request id、记录访问日志、解析可信代理 IP。 +2. `proxy/auth` 校验客户端 API Key。 +3. `proxy/openai` 解析 OpenAI compatible 请求。 +4. `proxy/dispatch` 构造账号获取请求,处理 session affinity 和隐式续写。 +5. 账号池按模型、账号状态、quota、Cloudflare、并发槽和策略选择账号。 +6. `upstream/protocol` 转为 Codex backend 请求。 +7. `upstream/transport` 选择 HTTP、SSE 或 WebSocket 访问上游。 +8. 响应被转换回 OpenAI Responses/Models 格式。 +9. 请求记录、账号用量、模型用量、quota header、Cookie、turn state 和 session affinity 写回运行时和 SQLite。 + +## 账号调度 + +账号池位于 `upstream/accounts/pool.rs`。调度前先做硬过滤: + +- 账号状态必须是 `active`。 +- 模型必须被账号计划允许。 +- quota 必须可用。 +- Cloudflare 冷却必须结束。 +- 账号不在本次请求排除列表。 +- 当前在途槽位小于 `maxConcurrentPerAccount`。 + +策略: + +- `smart`:默认策略。按账号实时负载选择账号,依次比较当前在途槽位数、窗口请求数、窗口 token 数和历史请求数;负载相同,再参考额度窗口重置时间和最近使用时间。 +- `quota_reset_priority`:按额度窗口重置时间选择账号,优先使用更早重置的窗口;重置时间相同,再比较历史请求数和最近使用时间。 +- `round_robin`:按候选账号顺序循环选择。 +- `sticky`:按最近使用时间优先选择。 + +请求带有 `required_account_id` 时只尝试该账号。请求带有 session affinity 的 `preferred_account_id` 时,只有该账号仍在硬过滤结果中才会命中,否则回到当前调度策略。选中账号后立即占用在途槽位,释放时记录请求用量并更新窗口计数。 + +## Session Affinity + +Session affinity 由 `proxy/dispatch/session_affinity.rs` 和 `proxy/dispatch/responses/affinity.rs` 维护。 + +存储内容: + +- response id。 +- conversation id。 +- account id。 +- prompt/instructions 变体。 +- turn state。 +- function call ids。 +- 输入 token。 +- 过期时间。 + +行为: + +- 请求显式携带 `previous_response_id` 时,优先查找该 response 对应账号。 +- 请求没有 `previous_response_id` 但满足隐式续写条件时,按 conversation 查找最新 response 并补充上下文。 +- 当亲和账号不可用并切换到其它账号时,按请求类型和失败类型处理历史字段。 +- 上游返回 `previous_response_not_found`、未闭合 function call 等历史错误时,恢复请求历史并重试。 +- 成功响应会记录新的 response affinity,供后续请求继续使用。 + +## Quota 和账号状态 + +账号状态包括: + +- `active` +- `expired` +- `quota_exhausted` +- `refreshing` +- `disabled` +- `banned` + +quota 数据来源: + +- 管理端主动刷新账号 quota。 +- 后台 quota refresh 周期任务。 +- 上游 rate-limit header 被动同步。 +- 请求中遇到 429/限额错误时标记冷却。 + +quota 运行态字段包括 `quota_json`、`quota_fetched_at`、`quota_limit_reached`、`quota_verify_required`、`quota_cooldown_until`、当前窗口计数、窗口开始时间、窗口重置时间和窗口长度。 + +当 quota 冷却或窗口到期时,账号会恢复为可验证状态,并在下次使用前执行 quota verify。验证通过后继续使用;验证仍为耗尽时继续冷却或切换账号。 + +## Token Refresh + +token refresh 由 `RuntimeTokenRefreshService` 和 `runtime/tasks/token_refresh.rs` 执行。 + +行为: + +- 按 `refreshMarginSeconds` 判断 access token 是否需要提前刷新。 +- 用 `refreshConcurrency` 控制刷新并发。 +- 使用 `account_refresh_leases` 避免多任务重复刷新同一账号。 +- refresh token 缺失、失效、复用或账号被封禁时更新账号状态。 +- 设置更新后刷新策略热生效。 + +## WebSocket Transport + +Responses 请求可通过 WebSocket 访问上游 `/codex/responses`。WebSocket 层负责: + +- 构造 opening handshake 和首个 `response.create` 文本帧。 +- 将上游 WebSocket 事件转换为 SSE。 +- 聚合非流式响应或转发流式响应。 +- 解析上游错误帧、`response.incomplete`、rate-limit 事件和 metadata turn state。 +- 记录首内容帧延迟、WebSocket pool 决策和上游诊断信息。 + +WebSocket pool 配置: + +- `enabled`:是否启用连接池。 +- `max_age_ms`:连接最大生命周期。 +- `max_per_account`:单账号池 slot 上限。 +- `first_token_timeout_ms`:发出 `response.create` 后等待首个内容帧的绝对超时;`0` 表示禁用。 + +池行为: + +- pool key 由 base URL、账号 ID 和 conversation ID 组成。 +- idle 连接按 key 复用,busy 连接会让请求绕过池新建连接。 +- idle 连接由后台 pump 做 ping/pong 和关闭检测。 +- 连接超过 max age、被 pump 标记关闭、账号被驱逐或请求失败时丢弃。 +- 复用连接在首内容帧前失效时,丢弃连接并换新连接。 +- fresh 连接首内容帧超时时,丢弃当前连接并按配置重试。 +- 流式请求在返回下游前会预取到首个内容帧;这样首内容帧超时可以在响应开始前完成换连接。 + +## Fingerprint + +fingerprint 位于 `upstream/fingerprint.rs`。运行时持有一个 `RuntimeFingerprint` 快照,上游请求从该快照生成 Codex Desktop 风格请求头。 + +数据来源: + +- `config.yaml` 中的默认 fingerprint。 +- SQLite 中的当前 fingerprint。 +- 后台 fingerprint update 任务。 + +启动时会用配置默认值初始化缺失的当前 fingerprint。后台任务从 Codex Desktop appcast 获取版本信息,更新 SQLite 中的 current fingerprint,并替换运行时快照。更新后的请求使用最新 fingerprint。 + +## 后台任务 + +后台任务由 `runtime/tasks/coordinator.rs` 统一启动和关闭: + +- `cookie_cleanup`:清理过期 Cookie。 +- `session_cleanup`:清理过期管理端 session。 +- `session_affinity_cleanup`:清理过期 session affinity。 +- `model_refresh`:按账号计划刷新模型列表和模型计划快照。 +- `token_refresh`:刷新即将过期的 access token。 +- `quota_refresh`:主动刷新账号 quota,并同步账号池状态。 +- `fingerprint_update`:更新 Codex Desktop fingerprint。 +- `websocket_pool`:关闭阶段统一 shutdown WebSocket pool。 + +周期任务通过统一 runner 管理 shutdown 信号和超时退出。 + +## 监控和用量 + +请求完成后写入: + +- `usage_records`:请求、上游尝试、状态码、失败分类、延迟、metadata。 +- `usage_time_buckets`:按中国时区 15 分钟槽聚合请求数、错误数、token、缓存、首内容帧延迟和总延迟。 +- `account_usage`:账号累计用量和当前 quota 窗口用量。 +- `account_model_usage`:账号和模型维度用量。 + +Dashboard: + +- `/api/admin/dashboard/summary` +- `/api/admin/dashboard/trend?kind=usage|latency|errors` + +Dashboard 的今日指标、趋势和健康时间线按中国自然日计算。账号容量、账号状态、服务状态和调度策略取当前运行时状态。累计请求、累计 token、累计缓存和累计命中率来自账号用量汇总。 + +请求明细: + +- `/api/admin/usage/records` +- `/api/admin/usage/records/summary` +- `/api/admin/usage/records/detail` +- `/api/admin/usage/records/insights/models` +- `/api/admin/usage/records/insights/endpoints` +- `/api/admin/usage/records/insights/token-trend` +- `/api/admin/usage/records/insights/latency-trend` +- `/api/admin/usage/records/delete` + +## 管理端 API + +管理端 API 位于 `/api/admin/*`,使用管理员 session cookie 保护。 + +主要路由: + +- Auth:`/api/admin/login`、`/api/admin/auth/status`、`/api/admin/logout`。 +- Settings:`/api/admin/settings`、`/api/admin/settings/admin-api-key`。 +- System:`/api/admin/system/version`、`/api/admin/system/update-detail`、`/api/admin/system/update-events`、`/api/admin/system/update-status`、`/api/admin/system/update`、`/api/admin/system/restart`、`/api/admin/system/rollback`。 +- Dashboard:`/api/admin/dashboard/summary`、`/api/admin/dashboard/trend`。 +- Accounts:`/api/admin/accounts`、`/api/admin/accounts/import`、`/api/admin/accounts/export`、`/api/admin/accounts/oauth/*`、`/api/admin/accounts/test`、`/api/admin/accounts/models`、`/api/admin/accounts/refresh`、`/api/admin/accounts/health-check`、`/api/admin/accounts/quota`。 +- Usage Records:`/api/admin/usage/records*`。 +- API Keys:`/api/admin/keys`、`/api/admin/keys/update`、`/api/admin/keys/delete`。 + +## 前端 + +前端由后端托管,不单独部署 Node 服务。 + +结构: - `frontend/src/api/modules`:管理端 API 客户端。 - `frontend/src/components/base`:基础 UI 组件。 @@ -128,77 +349,32 @@ Docker 也使用宿主机 `.runtime`,但容器内配置路径仍然写 `/app/d - `frontend/src/stores`:Pinia 状态。 - `frontend/src/styles/tokens.css`:亮色/暗色主题 token。 -管理端页面包括: +主要页面: -- 账号管理:导入、OAuth、连接测试、额度、模型、调度状态、刷新能力。 -- API Key 管理:客户端 Key 和管理员 API Key。 -- Dashboard:核心指标、请求趋势、账号概览、服务状态和健康时间线。 -- 请求明细:网关调用、上游状态、Token 消耗、错误和详情。 -- 设置:运行参数、模型映射、账号选择策略。 -- 系统更新:版本、检查更新、SSE 更新日志、更新、重启、回滚。 - -## 发布 - -版本契约集中在 `release/`: - -- `release/version.yaml`:当前产品版本。 -- `release/platforms.yaml`:Release asset 和 Docker 平台矩阵。 -- `release/publish`:更新版本文件、生成版本提交、创建 tag 并 push。 - -发布命令: - -```bash -release/publish 1.0.4 -``` - -发布 workflow 校验触发 tag 必须等于 `v` + `release/version.yaml`。构建时注入: - -- `CPR_VERSION` -- `CPR_GIT_SHA` -- `CPR_BUILD_TIME` -- `CPR_BUILD_TYPE=release` - -`backend/build/build.rs` 负责把这些值写入二进制。没有 `CPR_VERSION` 时,版本回落到 `release/version.yaml`,最后才回落到 Cargo 包版本。 - -Release 产物: - -```text -codex-proxy-rs__linux_amd64.tar.gz -codex-proxy-rs__linux_arm64.tar.gz -codex-proxy-rs__darwin_arm64.tar.gz -codex-proxy-rs__windows_amd64.zip -checksums.txt -``` - -GHCR 镜像发布 `linux/amd64` 和 `linux/arm64` 多平台 manifest。发布 workflow 先在对应原生 runner 上构建平台镜像,再合并正式 manifest。Docker 在线更新使用的 Linux 二进制在 `rust:1.95-bookworm` 中构建,保持与 runtime 镜像的 glibc ABI 兼容。 +- 首页 Dashboard。 +- 账号管理。 +- API Key 管理。 +- 请求明细。 +- 系统设置。 +- 系统更新。 ## 在线更新 在线更新由主服务处理,没有独立 updater sidecar。 -核心接口: - -- `GET /api/admin/system/version` -- `GET /api/admin/system/update-detail` -- `GET /api/admin/system/update-events` -- `GET /api/admin/system/update-status` -- `POST /api/admin/system/update` -- `POST /api/admin/system/restart` -- `POST /api/admin/system/rollback` - -更新流程: +流程: 1. 查询 GitHub Release。 -2. 按当前 OS/arch 选择 asset,支持 `amd64/x86_64` 与 `arm64/aarch64` 命名。 -3. 下载归档和 `checksums.txt`。 +2. 按当前 OS/arch 选择 asset。 +3. 下载归档和 checksum 文件。 4. 校验 checksum。 -5. 解压二进制和 `web/dist`。 +5. 解压后端二进制和 `web/dist`。 6. 替换当前二进制和前端静态资源。 7. 写入 `.runtime/data/update-state.json`。 8. 通过 SSE 推送中文更新日志。 -9. 管理端触发重启;Docker 模式依靠 `restart: unless-stopped` 拉起新容器进程,非 Docker 模式先安排新进程延迟启动,再关闭当前进程。 +9. 管理端触发重启。 -替换逻辑必须支持跨文件系统 fallback,因为容器环境中 `rename` 可能遇到 `Invalid cross-device link`。 +Docker 模式依靠 `restart: unless-stopped` 拉起新进程。非 Docker 模式会安排新进程延迟启动,再关闭当前进程。 ## Docker @@ -207,10 +383,10 @@ GHCR 镜像发布 `linux/amd64` 和 `linux/arm64` 多平台 manifest。发布 wo 阶段: - `frontend-builder`:安装前端依赖并构建 Vue SPA。 -- `backend-builder`:构建 Rust release 二进制,不依赖前端 dist。 +- `backend-builder`:构建 Rust release 二进制。 - `release-asset-builder` / `release-asset`:组合后端二进制和前端 dist,生成 GitHub Release 归档。 -- `runtime-base`:Debian slim、CA、curl、非 root 用户、运行目录。 -- `runtime`:从源码构建出的运行镜像。 +- `runtime-base`:Debian slim、CA、curl、非 root 用户和运行目录。 +- `runtime`:源码构建运行镜像。 - `runtime-prebuilt`:发布 workflow 使用的预构建二进制镜像阶段。 Compose 默认: @@ -222,6 +398,30 @@ Compose 默认: - 配置:`.runtime/config.yaml:/app/config.yaml:ro`。 - 更新:`CPR_UPDATE_REPOSITORY=zyycn/codex-proxy-rs`,`CPR_ENABLE_SELF_RESTART=true`。 +## 发布 + +版本文件: + +- `release/version.yaml` +- `release/platforms.yaml` + +发布命令: + +```bash +release/publish +``` + +发布 workflow 校验触发 tag 必须等于 `v` + `release/version.yaml`。 + +构建时注入: + +- `CPR_VERSION` +- `CPR_GIT_SHA` +- `CPR_BUILD_TIME` +- `CPR_BUILD_TYPE=release` + +Release 产物按 `release/platforms.yaml` 生成 Linux、Darwin 和 Windows 包。GHCR 镜像发布 `linux/amd64` 和 `linux/arm64` 多平台 manifest。 + ## CI 常规 CI 位于 `.github/workflows/ci.yml`: diff --git a/frontend/src/views/dashboard/components/AccountOverviewCard.vue b/frontend/src/views/dashboard/components/AccountOverviewCard.vue index 4e31053bf..a4632bca5 100644 --- a/frontend/src/views/dashboard/components/AccountOverviewCard.vue +++ b/frontend/src/views/dashboard/components/AccountOverviewCard.vue @@ -39,7 +39,8 @@ const strategyLabel = computed(() => { const s = props.rotationStrategy if (!s) return '—' const map: Record = { - least_used: '智能分配(推荐)', + smart: '智能调度(推荐)', + quota_reset_priority: '额度重置优先', round_robin: '轮询', sticky: '粘滞', } diff --git a/frontend/src/views/settings/components/RotationStrategyCard.vue b/frontend/src/views/settings/components/RotationStrategyCard.vue index fc40f2586..42ae58530 100644 --- a/frontend/src/views/settings/components/RotationStrategyCard.vue +++ b/frontend/src/views/settings/components/RotationStrategyCard.vue @@ -1,7 +1,7 @@