mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-15 08:06:21 +00:00
feat: add smart account scheduling
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user