feat: add smart account scheduling

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-07-07 17:00:56 +08:00
co-authored by Codex
parent 2e121c0670
commit 95daf7093e
19 changed files with 745 additions and 181 deletions
+2 -2
View File
@@ -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 {
+6 -4
View File
@@ -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<String, SettingsServiceE
} else {
Err(invalid_field(
"rotationStrategy",
"must be one of least_used, round_robin, sticky",
"must be one of smart, quota_reset_priority, round_robin, sticky",
))
}
}
@@ -551,9 +551,11 @@ pub fn account_pool_options_from_config(config: &AppConfig) -> 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,
}
}
+13 -6
View File
@@ -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,
@@ -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
);
@@ -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;
+81 -12
View File
@@ -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<String>,
}
#[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<Account> {
fn select_smart(&mut self, candidates: &[Account]) -> Option<Account> {
self.select_by(candidates, |pool, a, b| pool.compare_smart(a, b))
}
fn select_quota_reset_priority(&mut self, candidates: &[Account]) -> Option<Account> {
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<Account> {
let mut sorted = candidates.iter().collect::<Vec<_>>();
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<Account> {
let mut selected = candidates.first()?;
for candidate in &candidates[1..] {
@@ -1460,7 +1521,7 @@ fn refresh_cloudflare_cooldown(account: &mut Account, now: DateTime<Utc>) {
}
}
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<DateTime<Utc>>, b: Option<DateTime<Utc>>) -> Ordering {
match (a, b) {
(Some(a), Some(b)) => a.cmp(&b),
@@ -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)]
+1 -1
View File
@@ -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()
};
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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,
+9 -3
View File
@@ -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;
+1 -1
View File
@@ -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(),
@@ -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);
@@ -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::<BTreeSet<_>>()
.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::<BTreeSet<_>>()
.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);
@@ -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 =
+320 -120
View File
@@ -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<br/>/v1/responses"| Http["Axum Router"]
Browser["浏览器"] -->|"管理端 SPA"| Web["web/dist"]
Browser -->|"/api/admin/*"| Http
Browser["浏览器"] -->|"管理端 SPA<br/>/api/admin/*"| Http
subgraph App["codex-proxy-rs"]
Http --> Proxy["proxy<br/>Responses / Models 入口<br/>客户端 API Key 鉴权"]
Http --> Admin["admin<br/>账号 / Key / 设置 / 监控 / 系统更新"]
Http --> Web
Http --> Proxy["proxy<br/>OpenAI compatible routes"]
Http --> Admin["admin<br/>accounts / keys / monitoring / system"]
Http --> Web["web<br/>static SPA"]
Proxy --> Dispatch["dispatch<br/>账号选择<br/>限流 / quota / 亲和性 / 回退"]
Dispatch --> Protocol["upstream protocol<br/>请求转换<br/>SSE / 错误映射"]
Proxy --> Dispatch["dispatch<br/>account selection / affinity / retry"]
Dispatch --> Protocol["upstream protocol<br/>request / SSE / error mapping"]
Protocol --> Transport["upstream transport<br/>HTTP / SSE / WebSocket"]
Admin --> Runtime["runtime services<br/>账号池 / 后台任务 / 更新状态"]
Dispatch --> Runtime
Runtime --> Store[("SQLite<br/>账号 / Key / session / 用量 / 设置")]
Admin --> Store
Admin --> Services["runtime services"]
Dispatch --> Services
Services --> Store[("SQLite")]
end
Transport --> Upstream["ChatGPT / Codex Upstream"]
Release["GitHub Release<br/>archives + checksums"] --> Updater["self update<br/>下载 / 校验 / 替换 / 重启"]
Updater --> Admin
GHCR["GHCR image<br/>linux/amd64 + linux/arm64"] --> Docker["Docker Compose<br/>.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_<version>_linux_amd64.tar.gz
codex-proxy-rs_<version>_linux_arm64.tar.gz
codex-proxy-rs_<version>_darwin_arm64.tar.gz
codex-proxy-rs_<version>_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 <version>
```
发布 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`:
@@ -39,7 +39,8 @@ const strategyLabel = computed(() => {
const s = props.rotationStrategy
if (!s) return '—'
const map: Record<string, string> = {
least_used: '智能分配(推荐)',
smart: '智能调度(推荐)',
quota_reset_priority: '额度重置优先',
round_robin: '轮询',
sticky: '粘滞',
}
@@ -1,7 +1,7 @@
<script setup lang="ts">
import BaseCard from '@/components/base/BaseCard.vue'
type RotationStrategy = 'least_used' | 'round_robin' | 'sticky'
type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' | 'sticky'
interface RotationOption {
label: string
@@ -24,7 +24,7 @@ const model = defineModel<RotationStrategy>({ required: true })
header-class="px-5 pt-4"
body-class="px-5 py-5"
>
<div class="grid max-w-6xl gap-3 lg:grid-cols-3">
<div class="grid max-w-6xl gap-3 lg:grid-cols-4">
<button
v-for="option in options"
:key="option.value"
+10 -5
View File
@@ -20,7 +20,7 @@ import ModelAliasesCard from './components/ModelAliasesCard.vue'
import RotationStrategyCard from './components/RotationStrategyCard.vue'
import RuntimeSettingsCard from './components/RuntimeSettingsCard.vue'
type RotationStrategy = 'least_used' | 'round_robin' | 'sticky'
type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin' | 'sticky'
interface AliasRow {
alias: string
@@ -63,7 +63,7 @@ const form = reactive<SettingsForm>({
refreshConcurrency: 2,
maxConcurrentPerAccount: 3,
requestIntervalMs: 50,
rotationStrategy: 'least_used',
rotationStrategy: 'smart',
})
const aliasRows = ref<AliasRow[]>([])
@@ -74,8 +74,13 @@ const adminApiKeyStatus = reactive<AdminApiKeyStatus>({
const rotationOptions: RotationOption[] = [
{
label: '智能分配(推荐)',
value: 'least_used',
label: '智能调度(推荐)',
value: 'smart',
description: '优先避开高并发压力账号,并结合窗口用量和历史用量打散请求。',
},
{
label: '额度重置优先',
value: 'quota_reset_priority',
description: '优先使用即将刷新额度的账号,最大化总使用量。',
},
{
@@ -144,7 +149,7 @@ function applySettings(data: any) {
form.refreshConcurrency = Number(data.refreshConcurrency ?? 2)
form.maxConcurrentPerAccount = Number(data.maxConcurrentPerAccount ?? 3)
form.requestIntervalMs = Number(data.requestIntervalMs ?? 50)
form.rotationStrategy = (data.rotationStrategy || 'least_used') as RotationStrategy
form.rotationStrategy = (data.rotationStrategy || 'smart') as RotationStrategy
aliasRows.value = modelAliasesToRows(form.modelAliases)
aliasError.value = ''
}