feat(openai): configure Guardian concurrency priority at runtime (#306)

Configure Guardian reservations through persisted runtime settings and frozen request policies. Combine concurrency controls and queues in the admin UI, with the scheduling strategy shown first.

Prioritize Guardian account waiters, recheck displaced queue heads before lease acquisition, and report request-specific available capacity. Preserve account, key and wait-budget limits.

Co-authored-by: Yongzhao Chen <yongzhao.derek@gmail.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Yongzhao
2026-10-01 22:49:11 +08:00
committed by GitHub
co-authored by Yongzhao Chen Zyy Claude Opus 5.5 Codex
parent a0e8f768dd
commit 90e241f74b
45 changed files with 820 additions and 158 deletions
@@ -38,6 +38,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
@@ -77,6 +78,7 @@ pub struct ReplaceRuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
@@ -182,6 +184,7 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy: settings.rotation_strategy,
@@ -1114,6 +1114,7 @@ impl SettingsStore for StaticSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
@@ -37,6 +37,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
@@ -936,6 +936,7 @@ impl SettingsStore for FixtureSettingsStore {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
@@ -285,6 +285,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
@@ -54,6 +54,7 @@ pub struct RuntimeSettingsView {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
@@ -99,6 +100,7 @@ pub struct UpdateRuntimeSettingsRequest {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
@@ -234,6 +236,7 @@ impl UpdateRuntimeSettingsRequest {
max_waiting_per_key: self.max_waiting_per_key,
max_waiting_per_account: self.max_waiting_per_account,
concurrency_wait_timeout_seconds: self.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: self.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: self.responses_max_decompressed_body_bytes,
smart_scheduling: self.smart_scheduling,
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
@@ -283,6 +286,7 @@ impl From<(RuntimeSettings, crate::time::TimePresenter)> for RuntimeSettingsView
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
smart_scheduling_defaults: gateway_core::account::SmartSchedulingConfig::default(),
@@ -570,6 +570,7 @@ impl SettingsStore for MemorySettingsStore {
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: command.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: command.responses_max_decompressed_body_bytes,
smart_scheduling: command.smart_scheduling,
rotation_strategy: command.rotation_strategy,
@@ -1578,6 +1579,7 @@ fn test_runtime_settings() -> RuntimeSettings {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::Smart,
@@ -50,6 +50,7 @@ fn update_body() -> Value {
"requestIntervalMs": 25,
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"openaiGuardianReservedConcurrency": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
@@ -220,6 +221,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: RotationStrategy::RoundRobin,
@@ -268,6 +270,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"requestIntervalMs": 25,
"maxWaitingPerKey": 0,
"maxWaitingPerAccount": 0,
"openaiGuardianReservedConcurrency": 0,
"concurrencyWaitTimeoutSeconds": 30,
"responsesMaxDecompressedBodyBytes": 67108864,
"rotationStrategy": "round_robin",
@@ -337,6 +340,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: request.smart_scheduling,
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
@@ -1276,3 +1280,50 @@ async fn settings_update_rejects_a_stale_version_without_replacing_the_saved_val
9999
);
}
#[tokio::test]
async fn guardian_reservation_round_trips_and_rejects_invalid_values() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
let mut revision = response_json(response).await["data"]["configRevision"].clone();
for reserved in [1_u32, u32::MAX, 0] {
let mut body = update_body();
body["configRevision"] = revision;
body["openaiGuardianReservedConcurrency"] = json!(reserved);
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"]["openaiGuardianReservedConcurrency"],
reserved
);
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
let data = response_json(response).await["data"].clone();
revision = data["configRevision"].clone();
assert_eq!(data["openaiGuardianReservedConcurrency"], reserved);
}
for invalid in [json!(-1), json!(1.5), json!(4294967296_u64)] {
let mut body = update_body();
body["openaiGuardianReservedConcurrency"] = invalid;
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
}
let mut omitted = update_body();
omitted
.as_object_mut()
.unwrap()
.remove("openaiGuardianReservedConcurrency");
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(omitted).is_err());
}
@@ -90,6 +90,18 @@ impl AccountConcurrency {
Self::Limited(limit) => Some(limit),
}
}
/// 扣除当前请求不可占用的预留名额;不限并发不受影响,有限上限至少保留 1 个名额。
#[must_use]
pub const fn excluding_reserved(self, reserved: u32) -> Self {
match self {
Self::Unlimited => Self::Unlimited,
Self::Limited(limit) => match NonZeroU32::new(limit.get().saturating_sub(reserved)) {
Some(remaining) => Self::Limited(remaining),
None => Self::Limited(NonZeroU32::MIN),
},
}
}
}
impl From<NonZeroU32> for AccountConcurrency {
@@ -54,6 +54,7 @@ pub struct AccountSelectionPolicy {
max_concurrent_per_account: AccountConcurrency,
request_interval: Duration,
queue_policy: ConcurrencyQueuePolicy,
openai_guardian_reserved_concurrency: u32,
}
impl AccountSelectionPolicy {
@@ -68,6 +69,7 @@ impl AccountSelectionPolicy {
smart_scheduling: SmartSchedulingConfig::default(),
max_concurrent_per_account: max_concurrent_per_account.into(),
request_interval,
openai_guardian_reserved_concurrency: 0,
queue_policy: ConcurrencyQueuePolicy {
max_waiting: 0,
timeout: Duration::ZERO,
@@ -75,6 +77,18 @@ impl AccountSelectionPolicy {
}
}
/// 只传递冻结的运行设置,Guardian 分类与预留策略由 OpenAI Provider 解释。
#[must_use]
pub const fn with_openai_guardian_reserved_concurrency(mut self, reserved: u32) -> Self {
self.openai_guardian_reserved_concurrency = reserved;
self
}
#[must_use]
pub const fn openai_guardian_reserved_concurrency(self) -> u32 {
self.openai_guardian_reserved_concurrency
}
#[must_use]
pub const fn with_queue(mut self, policy: ConcurrencyQueuePolicy) -> Self {
self.queue_policy = policy;
@@ -433,6 +447,18 @@ pub struct AccountSelectionContext {
pub round_robin_cursor: u64,
pub eligibility: AccountEligibilityPolicy,
pub account_scope: Option<std::sync::Arc<crate::account::scope::FrozenAccountScope>>,
/// 本请求不可占用的每账号预留并发名额,由 Provider 按请求类别决定;0 表示不预留。
pub reserved_concurrency: u32,
}
impl AccountSelectionContext {
/// 本请求在该账号上可使用的并发上限;资格判断、租约与策略投影必须共用这一口径。
#[must_use]
pub fn concurrency_limit(&self, account: &ProviderAccount) -> AccountConcurrency {
account
.effective_concurrency(self.policy.max_concurrent_per_account())
.excluding_reserved(self.reserved_concurrency)
}
}
/// 选择账号时是否执行本地调度资格投影。
@@ -568,13 +594,8 @@ impl AccountSelector {
)
})
.try_fold((0_u64, 0_u64), |(used, total), candidate| {
let capacity = u64::from(
candidate
.account
.effective_concurrency(context.policy.max_concurrent_per_account())
.limit()?
.get(),
);
let capacity =
u64::from(context.concurrency_limit(&candidate.account).limit()?.get());
Some((
used.saturating_add(u64::from(candidate.signals.in_flight)),
total.saturating_add(capacity),
@@ -789,9 +810,8 @@ impl AccountSelector {
if context.excluded_accounts.contains(candidate.account.id()) {
return Some(AccountSchedulingBlocker::Excluded);
}
if candidate
.account
.effective_concurrency(context.policy.max_concurrent_per_account())
if context
.concurrency_limit(&candidate.account)
.limit()
.is_some_and(|limit| candidate.signals.in_flight >= limit.get())
{
+91 -24
View File
@@ -43,8 +43,21 @@ pub enum QueueRejection {
Timeout,
}
/// 同一队列内的等待类别:高优先级排在已有高优先级等待者之后、全部普通等待者之前。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WaitPriority {
#[default]
Normal,
High,
}
struct Waiter {
waker: Arc<AtomicWaker>,
priority: WaitPriority,
}
struct QueueState<K> {
queues: HashMap<K, VecDeque<Arc<AtomicWaker>>>,
queues: HashMap<K, VecDeque<Waiter>>,
total: usize,
}
@@ -75,6 +88,20 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
.is_some_and(|queue| !queue.is_empty())
}
/// 是否存在排在该类别请求之前的等待者;普通请求让位于全部等待者,高优先级只让位于同类。
fn has_waiters_ahead_of(&self, key: &K, priority: WaitPriority) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.queues
.get(key)
.is_some_and(|queue| {
queue
.iter()
.any(|waiter| priority == WaitPriority::Normal || waiter.priority == priority)
})
}
pub fn enqueue(
&self,
keys: &[K],
@@ -91,6 +118,18 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
keys: &[K],
max_waiting: u32,
deadline: Instant,
priority: impl FnMut(&K, usize) -> f64,
) -> Result<WaitTicket<K>, QueueRejection> {
self.enqueue_ranked(keys, max_waiting, deadline, WaitPriority::Normal, priority)
}
/// 高优先级等待者不受单队列 `max_waiting` 限制,但仍要求排队已开启并受全局容量约束。
fn enqueue_ranked(
&self,
keys: &[K],
max_waiting: u32,
deadline: Instant,
waiter_priority: WaitPriority,
mut priority: impl FnMut(&K, usize) -> f64,
) -> Result<WaitTicket<K>, QueueRejection> {
if Instant::now() >= deadline {
@@ -107,17 +146,30 @@ impl<K: Clone + Eq + Hash> ConcurrencyWaitQueue<K> {
.iter()
.filter_map(|key| {
let len = state.queues.get(key).map_or(0, VecDeque::len);
(len < max_waiting as usize).then(|| (key, priority(key, len)))
let admitted = max_waiting > 0
&& (waiter_priority == WaitPriority::High || len < max_waiting as usize);
admitted.then(|| (key, priority(key, len)))
})
.min_by(|(_, left), (_, right)| right.total_cmp(left))
.map(|(key, _)| key.clone())
.ok_or(QueueRejection::Full)?;
let waker = Arc::new(AtomicWaker::new());
state
.queues
.entry(key.clone())
.or_default()
.push_back(Arc::clone(&waker));
let queue = state.queues.entry(key.clone()).or_default();
let position = match waiter_priority {
WaitPriority::Normal => queue.len(),
WaitPriority::High => queue
.iter()
.position(|waiter| waiter.priority == WaitPriority::Normal)
.unwrap_or(queue.len()),
};
// 插到队首时原队首失去位置,它的有界重查会自然让出;新队首随后自行轮询到位。
queue.insert(
position,
Waiter {
waker: Arc::clone(&waker),
priority: waiter_priority,
},
);
state.total += 1;
Ok(WaitTicket {
state: Arc::clone(&self.state),
@@ -142,19 +194,20 @@ impl<K: Eq + Hash> WaitTicket<K> {
&self.key
}
fn is_head(&self) -> bool {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.queues
.get(&self.key)
.and_then(VecDeque::front)
.is_some_and(|head| Arc::ptr_eq(&head.waker, &self.waker))
}
pub async fn turn(&self) -> Result<(), QueueRejection> {
let ready = poll_fn(|cx| {
self.waker.register(cx.waker());
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state
.queues
.get(&self.key)
.and_then(VecDeque::front)
.is_some_and(|head| Arc::ptr_eq(head, &self.waker))
{
if self.is_head() {
Poll::Ready(())
} else {
Poll::Pending
@@ -188,11 +241,14 @@ impl<K: Eq + Hash> Drop for WaitTicket<K> {
let Some(queue) = state.queues.get_mut(&self.key) else {
return;
};
let Some(index) = queue.iter().position(|item| Arc::ptr_eq(item, &self.waker)) else {
let Some(index) = queue
.iter()
.position(|item| Arc::ptr_eq(&item.waker, &self.waker))
else {
return;
};
queue.remove(index);
let next = queue.front().cloned();
let next = queue.front().map(|waiter| Arc::clone(&waiter.waker));
if queue.is_empty() {
state.queues.remove(&self.key);
}
@@ -212,6 +268,7 @@ pub struct CapacityWait<'a, K: Eq + Hash> {
policy: ConcurrencyQueuePolicy,
request_deadline: SystemTime,
budget: &'a ConcurrencyWaitBudget,
priority: WaitPriority,
started_at: Option<Instant>,
ticket: Option<WaitTicket<K>>,
}
@@ -229,17 +286,26 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
policy,
request_deadline,
budget,
priority: WaitPriority::Normal,
started_at: None,
ticket: None,
}
}
#[must_use]
pub const fn with_priority(mut self, priority: WaitPriority) -> Self {
self.priority = priority;
self
}
#[must_use]
pub fn can_try(&self, key: &K) -> bool {
self.ticket
.as_ref()
.is_some_and(|ticket| ticket.key() == key)
|| !self.queue.has_waiters(key)
if let Some(ticket) = self.ticket.as_ref().filter(|ticket| ticket.key() == key) {
// 重读容量期间可能被高优先级请求插队,持有 ticket 不代表仍有选号资格。
ticket.is_head()
} else {
!self.queue.has_waiters_ahead_of(key, self.priority)
}
}
#[must_use]
@@ -271,10 +337,11 @@ impl<'a, K: Clone + Eq + Hash> CapacityWait<'a, K> {
self.ticket = None;
}
if self.ticket.is_none() {
self.ticket = Some(self.queue.enqueue_with_priority(
self.ticket = Some(self.queue.enqueue_ranked(
keys,
self.policy.max_waiting,
deadline,
self.priority,
priority,
)?);
}
@@ -63,6 +63,7 @@ impl TraceContext {
"preferredAccountId": context.preferred_account.as_ref().map(|id| id.as_str()),
"preferredResult": selection.as_ref().map(|s| format!("{:?}", s.preferred())),
"roundRobinCursor": context.round_robin_cursor,
"reservedConcurrency": context.reserved_concurrency,
"smartScoreTolerance": smart.then(|| context.policy.smart_scheduling().score_tolerance()),
"candidateCount": candidates.len(),
"omittedCandidates": candidates.len().saturating_sub(observations.len()),
@@ -427,17 +427,13 @@ impl RequestPolicyContext {
if eligible.is_empty() {
return Ok(None);
}
let policy = context.policy;
let projected = eligible
.iter()
.map(|candidate| AccountScheduleCandidate {
account_id: candidate.account.id().clone(),
weight: candidate.account.weight().get(),
in_flight: candidate.signals.in_flight,
maximum_concurrency: candidate
.account
.effective_concurrency(policy.max_concurrent_per_account())
.get(),
maximum_concurrency: context.concurrency_limit(&candidate.account).get(),
last_started_at: candidate.signals.last_started_at,
quota_reset_at: candidate.signals.quota_reset_at,
quota_remaining_rank: candidate.signals.quota_remaining_rank,
@@ -55,6 +55,7 @@ impl CompiledSettings {
AccountConcurrency::new(settings.max_concurrent_per_account),
Duration::from_millis(settings.request_interval_ms),
)
.with_openai_guardian_reserved_concurrency(settings.openai_guardian_reserved_concurrency)
.with_smart_scheduling(settings.smart_scheduling)
.with_queue(ConcurrencyQueuePolicy {
max_waiting: settings.max_waiting_per_account,
@@ -14,6 +14,7 @@ pub struct SettingsValues {
pub(crate) max_waiting_per_key: u32,
pub(crate) max_waiting_per_account: u32,
pub(crate) concurrency_wait_timeout_seconds: u32,
pub(crate) openai_guardian_reserved_concurrency: u32,
pub(crate) responses_max_decompressed_body_bytes: u64,
pub(crate) request_interval_ms: u64,
pub(crate) smart_scheduling: crate::account::SmartSchedulingConfig,
@@ -24,6 +25,12 @@ pub struct SettingsValues {
}
impl SettingsValues {
#[must_use]
pub const fn with_openai_guardian_reserved_concurrency(mut self, reserved: u32) -> Self {
self.openai_guardian_reserved_concurrency = reserved;
self
}
#[must_use]
pub fn request_profiles(&self) -> &BTreeMap<ProviderKind, crate::account::OpaqueProviderData> {
&self.request_profiles
@@ -104,6 +111,7 @@ impl SettingsValues {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
request_interval_ms,
smart_scheduling: crate::account::SmartSchedulingConfig::default(),
@@ -13,12 +13,13 @@ use std::time::{Duration, SystemTime};
use serde_json::{Map, Value};
use gateway_core::account::{
AccountAttemptFeedback, AccountCandidate, AccountConcurrencyLimit, AccountEligibilityPolicy,
AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals, AccountSchedulingBlocker,
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus, AccountWeight,
CredentialCasUpdate, CredentialRevision, CredentialState, OpaqueProviderData,
PlaintextCredential, PreferredAccountSelection, ProviderAccount, ProviderAccountId,
ProviderAccountIdentity, ProviderAccountUpdate, QuotaEvidence, QuotaState, RotationStrategy,
AccountAttemptFeedback, AccountCandidate, AccountConcurrency, AccountConcurrencyLimit,
AccountEligibilityPolicy, AccountFeedbackStats, AccountQuotaSignals, AccountRuntimeSignals,
AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector,
AccountStatus, AccountWeight, CredentialCasUpdate, CredentialRevision, CredentialState,
OpaqueProviderData, PlaintextCredential, PreferredAccountSelection, ProviderAccount,
ProviderAccountId, ProviderAccountIdentity, ProviderAccountUpdate, QuotaEvidence, QuotaState,
RotationStrategy,
};
use gateway_core::routing::{
ClientRoutingScope, FrozenAccountScope, ProviderKind, RuntimeAccount, RuntimeAccountDirectory,
@@ -109,6 +110,7 @@ fn context(strategy: RotationStrategy) -> AccountSelectionContext {
round_robin_cursor: 0,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: None,
reserved_concurrency: 0,
}
}
@@ -1093,3 +1095,42 @@ fn due_probe_freeze_blocks_scheduling_and_remains_visible_as_rate_limited() {
.expect("available account");
assert_eq!(selected.candidate().account.id().as_str(), "acct_ready");
}
#[test]
fn reserved_concurrency_only_blocks_requests_that_cannot_use_the_reserved_slots() {
let candidates = [candidate_with_concurrency("acct_reserved", 4, 5)];
let mut normal = context(RotationStrategy::Smart);
normal.reserved_concurrency = 1;
assert_eq!(normal.concurrency_limit(&candidates[0].account).get(), 4);
assert!(AccountSelector.select(&candidates, &normal).is_none());
let capacity = AccountSelector
.capacity_snapshot(&candidates, &normal)
.unwrap();
assert_eq!((capacity.used_slots(), capacity.total_slots()), (4, 4));
// 预留造成的暂满仍是可等待的容量约束,不能被当成不可用账号。
assert_eq!(
AccountSelector.wait_candidates(&candidates, &normal).len(),
1
);
let prioritized = context(RotationStrategy::Smart);
let capacity = AccountSelector
.capacity_snapshot(&candidates, &prioritized)
.unwrap();
assert_eq!((capacity.used_slots(), capacity.total_slots()), (4, 5));
let selected = AccountSelector
.select(&candidates, &prioritized)
.expect("reserved slot available");
assert_eq!(selected.candidate().account.id().as_str(), "acct_reserved");
let full = [candidate_with_concurrency("acct_reserved", 5, 5)];
assert!(AccountSelector.select(&full, &prioritized).is_none());
// 预留不能让普通请求失去全部名额,也不改变不限并发。
let mut oversized = context(RotationStrategy::Smart);
oversized.reserved_concurrency = 10;
assert_eq!(oversized.concurrency_limit(&candidates[0].account).get(), 1);
assert_eq!(
AccountConcurrency::Unlimited.excluding_reserved(2),
AccountConcurrency::Unlimited
);
}
@@ -3,7 +3,7 @@ use std::time::{Duration, Instant, SystemTime};
use futures::{FutureExt, executor::block_on};
use gateway_core::concurrency::{
CapacityWait, ConcurrencyQueuePolicy, ConcurrencyWaitBudget, ConcurrencyWaitQueue,
QueueRejection,
QueueRejection, WaitPriority,
};
#[test]
@@ -231,3 +231,97 @@ fn changed_scores_keep_existing_position_until_account_becomes_ineligible() {
assert!(!queue.has_waiters(&"b"));
});
}
#[test]
fn high_priority_waiters_go_ahead_of_normal_waiters_and_keep_fifo_among_themselves() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let deadline = Instant::now() + Duration::from_secs(2);
let policy = ConcurrencyQueuePolicy {
max_waiting: 2,
timeout: Duration::from_secs(2),
};
let request_deadline = SystemTime::now() + Duration::from_secs(2);
let first_normal = queue.enqueue(&["a"], 2, deadline).unwrap();
let second_normal = queue.enqueue(&["a"], 2, deadline).unwrap();
let normal_budget = ConcurrencyWaitBudget::default();
let normal = CapacityWait::new(&queue, policy, request_deadline, &normal_budget);
let first_budget = ConcurrencyWaitBudget::default();
let mut first_high = CapacityWait::new(&queue, policy, request_deadline, &first_budget)
.with_priority(WaitPriority::High);
// 只有普通等待者时,高优先级请求可以直接尝试租约,普通请求仍须排队。
assert!(first_high.can_try(&"a"));
assert!(!normal.can_try(&"a"));
// 普通等待者已占满单队列上限,高优先级仍可入队并直接成为队首。
first_high.wait(&["a"]).await.unwrap();
let second_budget = ConcurrencyWaitBudget::default();
let mut second_high = CapacityWait::new(&queue, policy, request_deadline, &second_budget)
.with_priority(WaitPriority::High);
assert!(!second_high.can_try(&"a"));
assert!(second_high.wait(&["a"]).now_or_never().is_none());
assert!(first_normal.turn().now_or_never().is_none());
drop(first_high);
second_high.wait(&["a"]).await.unwrap();
assert!(first_normal.turn().now_or_never().is_none());
drop(second_high);
first_normal.turn().await.unwrap();
drop(first_normal);
second_normal.turn().await.unwrap();
});
}
#[test]
fn displaced_normal_head_yields_its_lease_attempt_until_high_priority_waiters_leave() {
block_on(async {
let queue = ConcurrencyWaitQueue::default();
let policy = ConcurrencyQueuePolicy {
max_waiting: 2,
timeout: Duration::from_secs(2),
};
let deadline = SystemTime::now() + Duration::from_secs(2);
let normal_budget = ConcurrencyWaitBudget::default();
let high_budget = ConcurrencyWaitBudget::default();
let mut normal = CapacityWait::new(&queue, policy, deadline, &normal_budget);
normal.wait(&["account"]).await.unwrap();
assert!(normal.can_try(&"account"));
// 普通队首已经开始重读容量,尚未取得租约时被高优先级请求插队。
let mut high = CapacityWait::new(&queue, policy, deadline, &high_budget)
.with_priority(WaitPriority::High);
high.wait(&["account"]).await.unwrap();
assert!(!normal.can_try(&"account"));
assert!(high.can_try(&"account"));
assert!(normal.wait(&["account"]).now_or_never().is_none());
// 审批取消也必须恢复原队首的资格,不能丢失其等待位置。
drop(high);
normal.wait(&["account"]).await.unwrap();
assert!(normal.can_try(&"account"));
drop(normal);
assert!(!queue.has_waiters(&"account"));
});
}
#[test]
fn high_priority_waiters_still_require_queueing_to_be_enabled() {
block_on(async {
let queue = ConcurrencyWaitQueue::<&str>::default();
let policy = ConcurrencyQueuePolicy {
max_waiting: 0,
timeout: Duration::from_secs(2),
};
let budget = ConcurrencyWaitBudget::default();
let mut waiting = CapacityWait::new(
&queue,
policy,
SystemTime::now() + Duration::from_secs(2),
&budget,
)
.with_priority(WaitPriority::High);
assert_eq!(waiting.wait(&["a"]).await, Err(QueueRejection::Full));
assert!(!queue.has_waiters(&"a"));
});
}
@@ -1921,6 +1921,7 @@ impl Provider for PolicySelectingProvider {
round_robin_cursor: 0,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: attempt.account_scope().cloned(),
reserved_concurrency: 0,
};
match attempt
.select_account(
@@ -116,6 +116,7 @@ fn context() -> AccountSelectionContext {
round_robin_cursor: 0,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: None,
reserved_concurrency: 0,
}
}
@@ -364,7 +364,7 @@ fn compiler_accepts_unlimited_default_account_concurrency() {
}
#[test]
fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
fn compiled_plans_keep_their_scheduling_config_after_a_new_snapshot_is_built() {
use gateway_core::account::SmartSchedulingConfig;
let configs = [
SmartSchedulingConfig::default(),
@@ -376,7 +376,8 @@ fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
revision(index as u64 + 1),
revision(index as u64 + 1),
SettingsValues::new(3, 0, "smart", BTreeMap::new(), None, None)
.with_smart_scheduling(config),
.with_smart_scheduling(config)
.with_openai_guardian_reserved_concurrency(index as u32),
vec![],
vec![],
vec![SnapshotProviderAccountFacts::new(
@@ -401,7 +402,12 @@ fn compiled_plans_keep_their_smart_config_after_a_new_snapshot_is_built() {
.unwrap(),
);
}
for (plan, config) in plans.iter().zip(configs) {
for (index, (plan, config)) in plans.iter().zip(configs).enumerate() {
assert_eq!(
plan.account_selection_policy()
.openai_guardian_reserved_concurrency(),
index as u32
);
assert_eq!(plan.account_selection_policy().smart_scheduling(), config);
}
}
@@ -557,6 +557,7 @@ fn selection_context() -> AccountSelectionContext {
round_robin_cursor: 0,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: None,
reserved_concurrency: 0,
}
}
@@ -518,7 +518,7 @@ async fn concurrent_calls_keep_their_wire_ids_monotonic() {
tokio::time::sleep(Duration::from_millis(20)).await;
callbacks.release();
for call in calls {
assert!(call.await.unwrap().is_ok());
call.await.unwrap().unwrap();
}
session.shutdown(Duration::from_secs(1)).await;
}
@@ -27,6 +27,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
@@ -64,6 +65,7 @@ pub struct ReplaceRuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: SmartSchedulingConfig,
pub rotation_strategy: RotationStrategy,
@@ -258,6 +260,7 @@ impl From<RuntimeSettings> for ReplaceRuntimeSettings {
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy: settings.rotation_strategy,
@@ -93,6 +93,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
max_waiting_per_key: command.max_waiting_per_key,
max_waiting_per_account: command.max_waiting_per_account,
concurrency_wait_timeout_seconds: command.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: command.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: command
.responses_max_decompressed_body_bytes,
smart_scheduling: command.smart_scheduling,
@@ -131,6 +132,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
"max_waiting_per_key".to_owned(),
"max_waiting_per_account".to_owned(),
"concurrency_wait_timeout_seconds".to_owned(),
"openai_guardian_reserved_concurrency".to_owned(),
"responses_max_decompressed_body_bytes".to_owned(),
"rotation_strategy".to_owned(),
"smart_scheduling_json".to_owned(),
@@ -238,6 +240,7 @@ pub(crate) fn admin_runtime_settings(
max_waiting_per_key: settings.max_waiting_per_key,
max_waiting_per_account: settings.max_waiting_per_account,
concurrency_wait_timeout_seconds: settings.concurrency_wait_timeout_seconds,
openai_guardian_reserved_concurrency: settings.openai_guardian_reserved_concurrency,
responses_max_decompressed_body_bytes: settings.responses_max_decompressed_body_bytes,
smart_scheduling: settings.smart_scheduling,
rotation_strategy,
@@ -30,6 +30,7 @@ pub struct RuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
@@ -132,6 +133,7 @@ pub struct RuntimeSettingsUpdate {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
@@ -262,7 +264,7 @@ pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResul
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, min_codex_desktop_version,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency,
account_auto_freeze_enabled, account_auto_freeze_threshold,
account_auto_freeze_window_seconds, account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled, account_auto_freeze_probe_model,
@@ -442,7 +444,7 @@ pub(crate) async fn load_runtime_settings_in_transaction(
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, min_codex_desktop_version,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds,
min_codex_cli_version, updated_at, responses_max_decompressed_body_bytes, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency,
account_auto_freeze_enabled, account_auto_freeze_threshold,
account_auto_freeze_window_seconds, account_auto_freeze_duration_seconds,
account_auto_freeze_probe_enabled, account_auto_freeze_probe_model,
@@ -517,6 +519,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
account_warmup_schedule_time = $28,
account_warmup_model = $29,
smart_scheduling_json = $30,
openai_guardian_reserved_concurrency = $31,
updated_at = now()
where id = 1
returning config_revision",
@@ -563,6 +566,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
.bind(&update.account_warmup_schedule_time)
.bind(update.account_warmup_model.as_deref())
.bind(sqlx::types::Json(update.smart_scheduling))
.bind(i64::from(update.openai_guardian_reserved_concurrency))
.fetch_optional(&mut **transaction)
.await
.map_err(|_| postgres_unavailable("update runtime settings in transaction"))?
@@ -635,6 +639,7 @@ struct RuntimeSettingsRow {
max_waiting_per_key: i64,
max_waiting_per_account: i64,
concurrency_wait_timeout_seconds: i64,
openai_guardian_reserved_concurrency: i64,
responses_max_decompressed_body_bytes: i64,
account_auto_freeze_enabled: bool,
account_auto_freeze_threshold: i64,
@@ -688,6 +693,7 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
max_waiting_per_key: to_u32(row.max_waiting_per_key)?,
max_waiting_per_account: to_u32(row.max_waiting_per_account)?,
concurrency_wait_timeout_seconds: to_u32(row.concurrency_wait_timeout_seconds)?,
openai_guardian_reserved_concurrency: to_u32(row.openai_guardian_reserved_concurrency)?,
responses_max_decompressed_body_bytes: to_u64(row.responses_max_decompressed_body_bytes)?,
account_auto_freeze_enabled: row.account_auto_freeze_enabled,
account_auto_freeze_threshold: to_u32(row.account_auto_freeze_threshold)?,
@@ -32,6 +32,7 @@ pub struct SnapshotRuntimeSettings {
pub max_waiting_per_key: u32,
pub max_waiting_per_account: u32,
pub concurrency_wait_timeout_seconds: u32,
pub openai_guardian_reserved_concurrency: u32,
pub responses_max_decompressed_body_bytes: u64,
pub smart_scheduling: gateway_core::account::SmartSchedulingConfig,
pub rotation_strategy: String,
@@ -163,6 +164,9 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository {
.with_responses_max_decompressed_body_bytes(
data.settings.responses_max_decompressed_body_bytes,
)
.with_openai_guardian_reserved_concurrency(
data.settings.openai_guardian_reserved_concurrency,
)
.with_smart_scheduling(data.settings.smart_scheduling)
.with_request_profiles(data.settings.request_profiles)
.with_pricing(data.settings.pricing)
@@ -264,6 +268,7 @@ struct SnapshotSettingsRow {
max_waiting_per_key: i64,
max_waiting_per_account: i64,
concurrency_wait_timeout_seconds: i64,
openai_guardian_reserved_concurrency: i64,
request_location_json: sqlx::types::Json<gateway_core::account::RequestLocation>,
request_location_enabled: bool,
responses_max_decompressed_body_bytes: i64,
@@ -275,7 +280,7 @@ async fn load_settings(
transaction: &mut Transaction<'_, Postgres>,
) -> StoreResult<(Revision, SnapshotRuntimeSettings)> {
let row = sqlx::query_as::<_, SnapshotSettingsRow>(
"select config_revision, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
"select config_revision, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
)
.fetch_optional(&mut **transaction)
.await
@@ -313,6 +318,7 @@ async fn load_settings(
max_waiting_per_key: to_u32(row.max_waiting_per_key)?,
max_waiting_per_account: to_u32(row.max_waiting_per_account)?,
concurrency_wait_timeout_seconds: to_u32(row.concurrency_wait_timeout_seconds)?,
openai_guardian_reserved_concurrency: to_u32(row.openai_guardian_reserved_concurrency)?,
},
))
}
@@ -19,6 +19,7 @@ fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate {
max_waiting_per_key: 0,
max_waiting_per_account: 0,
concurrency_wait_timeout_seconds: 30,
openai_guardian_reserved_concurrency: 0,
responses_max_decompressed_body_bytes: 64 * 1024 * 1024,
smart_scheduling: gateway_core::account::SmartSchedulingConfig::default(),
rotation_strategy: "smart".to_owned(),
@@ -361,23 +362,26 @@ async fn concurrency_queue_settings_round_trip_into_the_runtime_snapshot() {
(
before.max_waiting_per_key,
before.max_waiting_per_account,
before.concurrency_wait_timeout_seconds
before.concurrency_wait_timeout_seconds,
before.openai_guardian_reserved_concurrency,
),
(0, 0, 30)
(0, 0, 30, 0)
);
let mut update = settings_with_margin(3600);
update.max_waiting_per_key = 5;
update.max_waiting_per_account = 7;
update.concurrency_wait_timeout_seconds = 12;
update.openai_guardian_reserved_concurrency = 1;
repository.update_runtime_settings(update).await.unwrap();
let settings = repository.load_runtime_settings().await.unwrap();
assert_eq!(
(
settings.max_waiting_per_key,
settings.max_waiting_per_account,
settings.concurrency_wait_timeout_seconds
settings.concurrency_wait_timeout_seconds,
settings.openai_guardian_reserved_concurrency,
),
(5, 7, 12)
(5, 7, 12, 1)
);
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
@@ -387,9 +391,10 @@ async fn concurrency_queue_settings_round_trip_into_the_runtime_snapshot() {
(
snapshot.settings.max_waiting_per_key,
snapshot.settings.max_waiting_per_account,
snapshot.settings.concurrency_wait_timeout_seconds
snapshot.settings.concurrency_wait_timeout_seconds,
snapshot.settings.openai_guardian_reserved_concurrency,
),
(5, 7, 12)
(5, 7, 12, 1)
);
assert!(snapshot.config_revision > before.config_revision);
database.close().await;
@@ -11,7 +11,7 @@ use gateway_core::account::{
AccountSelector, AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount,
ProviderAccountId, QuotaEvidence,
};
use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection};
use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection, WaitPriority};
use gateway_core::engine::{AttemptContext, ContinuationAttempt, policy::AccountPolicyError};
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort, ProviderLeaseRequest,
@@ -106,6 +106,8 @@ struct CredentialSelectionInput<'a> {
attempt: &'a AttemptContext,
session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
session_affinity_observation: Option<&'a CodexSessionAffinity>,
/// Codex Guardian 自动审批请求;仅在配置了预留名额时获得预留与队列优先。
guardian: bool,
}
#[derive(Clone)]
@@ -299,6 +301,7 @@ impl CodexCredentialSelector {
attempt: request.attempt,
session_affinity_key: request.session_affinity_key,
session_affinity_observation: None,
guardian: false,
};
self.select_inner(&input, None, Some(request.upstream_model))
.await
@@ -310,6 +313,7 @@ impl CodexCredentialSelector {
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
session_affinity_observation: Option<&CodexSessionAffinity>,
requires_websocket: bool,
guardian: bool,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let input = CredentialSelectionInput {
requires_websocket,
@@ -317,6 +321,7 @@ impl CodexCredentialSelector {
attempt: request.attempt,
session_affinity_key: request.session_affinity_key,
session_affinity_observation,
guardian,
};
self.select_inner(
&input,
@@ -376,6 +381,7 @@ impl CodexCredentialSelector {
attempt: request.attempt,
session_affinity_key: request.session_affinity.map(CodexSessionAffinity::key),
session_affinity_observation: request.session_affinity,
guardian: false,
};
self.select_inner(&input, None, None).await
}
@@ -387,12 +393,24 @@ impl CodexCredentialSelector {
upstream_model: Option<&str>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let queue_policy = request.attempt.account_selection_policy().queue_policy();
// 预留名额只对普通请求生效;Guardian 可用满全部名额,并在账号队列中排在普通请求之前。
let reserve = request
.attempt
.account_selection_policy()
.openai_guardian_reserved_concurrency();
let prioritized = request.guardian && reserve > 0;
let reserved_concurrency = if prioritized { 0 } else { reserve };
let mut waiting = CapacityWait::new(
&self.waiting,
queue_policy,
request.attempt.deadline(),
request.attempt.concurrency_wait_budget(),
);
)
.with_priority(if prioritized {
WaitPriority::High
} else {
WaitPriority::Normal
});
let continuation_account = match request.attempt.continuation_attempt() {
ContinuationAttempt::Native => request
.attempt
@@ -604,6 +622,7 @@ impl CodexCredentialSelector {
AccountEligibilityPolicy::Enforce
},
account_scope: request.attempt.account_scope().cloned(),
reserved_concurrency,
};
let wait_context = AccountSelectionContext {
excluded_accounts: base_excluded.clone(),
@@ -724,6 +743,11 @@ impl CodexCredentialSelector {
}
Err(error) => return Err(error.into()),
};
// 凭据重读和插件选号都可能挂起,取得租约前再次让位于新队首。
if !waiting.can_try(account.id()) {
excluded.insert(account.id().clone());
continue;
}
let allows_account_state_mutation = !diagnostic || account.enabled();
match self
.leases
@@ -732,7 +756,7 @@ impl CodexCredentialSelector {
self.provider_kind.clone(),
account.id().clone(),
account.revision(),
account.effective_concurrency(policy.max_concurrent_per_account()),
context.concurrency_limit(&account),
policy.request_interval(),
request.attempt.deadline(),
),
@@ -182,13 +182,11 @@ impl CodexProvider {
fn prepare_generate_request(
&self,
generate: &GenerateRequest,
upstream_model: &UpstreamModelId,
mut upstream: CodexResponsesRequest,
context: &AttemptContext,
) -> Result<PreparedGenerateRequest, ProviderError> {
) -> PreparedGenerateRequest {
let previous_session = decode_openai_session_state(generate);
let continuation_requested = generate.native_continuation_requested();
let mut upstream = encode_generate_request(generate, upstream_model.as_str(), None)
.map_err(map_request_error)?;
if let Some(conversation_id) = previous_session
.as_ref()
.and_then(|state| state.conversation_id.as_ref())
@@ -215,13 +213,13 @@ impl CodexProvider {
derive_codex_session_affinity(&upstream, context.client_api_key_ref());
let cyber_policy_session_key =
derive_codex_cyber_policy_session_key(&upstream, context.client_api_key_ref());
Ok(PreparedGenerateRequest {
PreparedGenerateRequest {
upstream,
previous_session,
continuation_requested,
session_affinity,
cyber_policy_session_key,
})
}
}
fn client_for_request(
@@ -517,10 +515,18 @@ impl Provider for CodexProvider {
}
// 其他协议必须先取得真实账号,再按固定 attempt 阶段调用转换器;选号前不能
// 把未知正文当成 OpenAI wire 解释会话、亲和或传输字段。
let preselection = (adapter.is_none()
&& generate.protocol_payload().protocol() == PROVIDER_NAME)
.then(|| self.prepare_generate_request(generate, upstream_model, &context))
.transpose()?;
let upstream = (generate.protocol_payload().protocol() == PROVIDER_NAME)
.then(|| encode_generate_request(generate, upstream_model.as_str(), None))
.transpose()
.map_err(map_request_error)?;
// 固定调度约束:Guardian 分类先于选号,不能随原生/适配器发送路径改变。
// 复用编码器的权威 metadata 解析,但只为原生路径准备会话与传输状态。
let guardian = upstream
.as_ref()
.is_some_and(CodexResponsesRequest::is_guardian);
let preselection = upstream
.filter(|_| adapter.is_none())
.map(|upstream| self.prepare_generate_request(generate, upstream, &context));
let (selection_session_affinity, selection_cyber_policy_key, requires_websocket) =
preselection.map_or((None, None, false), |prepared| {
let requires_websocket =
@@ -552,6 +558,7 @@ impl Provider for CodexProvider {
selection_cyber_policy_key.as_ref(),
selection_session_affinity.as_ref(),
requires_websocket,
guardian,
)
.await
.map_err(map_selection_error)
@@ -648,7 +655,9 @@ impl CodexProvider {
));
}
validate_openai_reasoning(generate.protocol_payload().body())?;
let processed = self.prepare_generate_request(&generate, &upstream_model, &context)?;
let upstream = encode_generate_request(&generate, upstream_model.as_str(), None)
.map_err(map_request_error)?;
let processed = self.prepare_generate_request(&generate, upstream, &context);
let mut upstream_request = processed.upstream;
let previous_session = processed.previous_session;
let continuation_requested = processed.continuation_requested;
@@ -15,6 +15,8 @@ pub(crate) const X_CODEX_TURN_STATE_CLIENT_METADATA_KEY: &str = "x-codex-turn-st
/// 本地生成 history unavailable 错误时使用的官方提示文本。
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE: &str =
"Previous response was not found. Retrying the full request.";
/// Codex 自动审批(Guardian)请求声明的子代理类型。
const GUARDIAN_SUBAGENT_KIND: &str = "guardian";
/// Codex Responses 上游请求体。
///
/// 发往上游的 Responses 请求。`body` 持有客户端原始 JSON object,逐字段(含顺序、
@@ -694,6 +696,11 @@ impl CodexResponsesRequest {
})
}
/// Codex 执行命令前的 Guardian 自动审批请求;客户端以 `guardian` 子代理类型声明。
pub fn is_guardian(&self) -> bool {
self.subagent_kind().as_deref() == Some(GUARDIAN_SUBAGENT_KIND)
}
/// 设置 / 合并 client metadata。
pub fn set_client_metadata(&mut self, client_metadata: Option<Value>) {
match client_metadata {
@@ -106,6 +106,7 @@ async fn old_weekly_quota_should_outweigh_one_in_flight_title_request() {
round_robin_cursor: cursor,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: None,
reserved_concurrency: 0,
};
let selected = AccountSelector
.select(&candidates, &context)
@@ -1,4 +1,5 @@
use futures::FutureExt;
use gateway_core::account::AccountRuntimeSignals;
use gateway_core::concurrency::ConcurrencyQueuePolicy;
use super::*;
@@ -129,3 +130,139 @@ async fn queued_session_sends_only_after_capacity_recovers_while_a_new_child_can
);
server.verify().await;
}
fn unqueued_context(request_id: &str, reserved: u32) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new(request_id).unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::new(1).unwrap(),
SystemTime::now() + Duration::from_secs(5),
account_policy().with_openai_guardian_reserved_concurrency(reserved),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
None,
CancellationToken::new(),
)
}
fn subagent_operation(subagent: Option<&str>) -> Operation {
let mut body = Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!("guardian reservation test")),
]);
if let Some(subagent) = subagent {
body.insert(
"client_metadata".to_owned(),
json!({"x-openai-subagent": subagent}),
);
}
Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body)
.unwrap()
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])),
))
}
#[tokio::test]
async fn guardian_requests_can_use_the_reserved_slot_that_normal_requests_cannot() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_guardian").await;
let account = ProviderAccountId::new("acct_guardian").unwrap();
let leases = Arc::new(TestLeaseCoordinator::default());
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/codex/responses"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(3)
.mount(&server)
.await;
// 默认账号上限为 2、预留 1:普通请求只能使用 1 个名额,Guardian 可以用满 2 个。
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
Arc::new(MemorySessionAffinity::default()),
server.uri(),
leases.clone(),
);
let mut normal = provider
.clone()
.execute(
planned_request("openai", subagent_operation(Some("collab_spawn"))),
unqueued_context("req_guardian_normal_idle", 1),
)
.await
.unwrap();
while let Some(event) = normal.next().await {
event.unwrap();
}
drop(normal);
let limits = |leases: &TestLeaseCoordinator| {
leases
.requests
.lock()
.unwrap()
.iter()
.map(|request| request.max_concurrent().get())
.collect::<Vec<_>>()
};
assert_eq!(limits(&leases), [1]);
leases.signals.lock().unwrap().insert(
account.clone(),
AccountRuntimeSignals {
in_flight: 1,
last_started_at: None,
quota_reset_at: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
first_output_latency_ms: None,
},
);
assert!(
provider
.clone()
.execute(
planned_request("openai", subagent_operation(None)),
unqueued_context("req_guardian_normal_busy", 1),
)
.await
.is_err()
);
assert_eq!(limits(&leases), [1]);
let mut guardian = provider
.clone()
.execute(
planned_request("openai", subagent_operation(Some("guardian"))),
unqueued_context("req_guardian_reserved", 1),
)
.await
.unwrap();
assert_eq!(guardian.metadata().provider_account_id(), &account);
while let Some(event) = guardian.next().await {
event.unwrap();
}
assert_eq!(limits(&leases), [1, 2]);
drop(guardian);
// 同一个 Provider 的新请求读取关闭后的策略,不需要重新初始化选择器。
let mut unreserved = provider
.execute(
planned_request("openai", subagent_operation(None)),
unqueued_context("req_guardian_reservation_disabled", 0),
)
.await
.unwrap();
while let Some(event) = unreserved.next().await {
event.unwrap();
}
assert_eq!(limits(&leases), [1, 2, 2]);
server.verify().await;
}
@@ -1339,6 +1339,7 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
"acct_continuation_prefetch",
"acct_disabled_scheduling",
"acct_first_event_latency",
"acct_guardian",
"acct_header_new",
"acct_header_old",
"acct_header_same",
@@ -1,4 +1,5 @@
use super::*;
use gateway_core::account::AccountRuntimeSignals;
use gateway_core::engine::upstream_adapter::{
UpstreamAccountConnection, UpstreamAdapter, UpstreamAdapterInvocation, UpstreamAdapterPlan,
};
@@ -257,6 +258,108 @@ async fn selected_adapter_connection(
receiver.await.unwrap()
}
#[tokio::test]
async fn guardian_reservation_survives_upstream_adapters_and_metadata_precedence() {
// 上限 2、已有 1 个在途请求:预留启用时只有 Guardian 能继续取得租约。
for (subagent, turn_kind, reserved, allowed) in [
(Some("guardian"), None, 1, true),
(None, Some("guardian"), 1, true),
(Some("collab_spawn"), Some("guardian"), 1, true),
(Some("guardian"), Some("collab_spawn"), 1, false),
(Some("collab_spawn"), None, 1, false),
(None, None, 1, false),
(None, None, 0, true),
] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let leases = Arc::new(TestLeaseCoordinator::default());
leases.signals.lock().unwrap().insert(
ProviderAccountId::new("acct_provider_contract").unwrap(),
AccountRuntimeSignals {
in_flight: 1,
last_started_at: None,
quota_reset_at: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
first_output_latency_ms: None,
},
);
let server = MockServer::start().await;
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
Arc::new(MemorySessionAffinity::default()),
server.uri(),
leases.clone(),
);
let (selected, receiver) = oneshot::channel();
let plan = gateway_core::engine::upstream_adapter::FrozenUpstreamAdapterPlan::new(
Arc::new(ConnectionProbe {
selected: Arc::new(Mutex::new(Some(selected))),
}),
ExtensionSetReference::new(
ExtensionSetId::new("guardian-adapter-test".to_owned()).unwrap(),
Arc::new(TestExtensionLease),
),
);
let context = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_guardian_adapter").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
)
.with_upstream_adapters(Some(plan)),
NonZeroU32::MIN,
SystemTime::now() + Duration::from_secs(5),
account_policy().with_openai_guardian_reserved_concurrency(reserved),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
None,
CancellationToken::new(),
);
let mut body = Map::from_iter([
("model".into(), json!("gpt-5.4")),
("input".into(), json!("guardian scheduling contract")),
]);
if let Some(kind) = subagent {
body.insert("client_metadata".into(), json!({"x-openai-subagent": kind}));
}
let mut protocol_context = Map::new();
if let Some(kind) = turn_kind {
protocol_context.insert(
"turn_metadata".into(),
json!(json!({"subagent_kind": kind}).to_string()),
);
}
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body)
.unwrap()
.with_context(protocol_context),
));
let result = provider
.execute(planned_request("openai", operation), context)
.await;
assert_eq!(
result.is_ok(),
allowed,
"{subagent:?}, {turn_kind:?}, reserve={reserved}"
);
if allowed {
let mut stream = result.unwrap();
assert_eq!(
stream.next().await.unwrap().unwrap_err().kind(),
ProviderErrorKind::Cancelled
);
drop(receiver.await.unwrap());
let requests = leases.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].max_concurrent().get(), 2);
} else {
assert!(leases.requests.lock().unwrap().is_empty());
}
assert!(server.received_requests().await.unwrap().is_empty());
}
}
#[tokio::test]
async fn upstream_adapter_reloads_rotated_credentials_and_proxy_without_accepting_stale_failure() {
use gateway_core::account::OutboundProxy;
@@ -230,6 +230,7 @@ impl GrokAccountSessionSelector {
round_robin_cursor: scheduling.round_robin_cursor(),
eligibility: request.eligibility(),
account_scope: (!diagnostic).then(|| Arc::clone(request.account_scope())),
reserved_concurrency: 0,
};
let wait_candidates = AccountSelector.wait_candidates(&candidates, &context);
let capacity_context = context.clone();
+1
View File
@@ -19,3 +19,4 @@ fcfae912ddefd54551ebf8e7e733442519d2862d2977134d548ebc1e75c1dc34 0017_unlimited
40a4d4b16e6ec2c2c659980d416b089fc170acd2255aaaf751c4a04c3790a941 0019_plugin_resources.sql
e8c1339166ac02fb2d4a077424ee5f64f6dc20eb4d3f8ec4fcb09c0f2a0e61f6 0020_plugin_trust.sql
338037806358b23dc8b62a39e73b04ce4173c4556383e5df19b7e8c7123924ed 0021_account_warmup_slots.sql
3b6924605e7e70c074d409611d13f17625f98fae4e9dbe8012a6b3dc3aa1679f 0022_guardian_reserved_concurrency.sql
@@ -0,0 +1,3 @@
alter table runtime_settings
add column openai_guardian_reserved_concurrency bigint not null default 0
check (openai_guardian_reserved_concurrency between 0 and 4294967295);
+8
View File
@@ -1218,6 +1218,7 @@ refreshConcurrency
maxConcurrentPerAccount
maxWaitingPerKey
maxWaitingPerAccount
openaiGuardianReservedConcurrency
concurrencyWaitTimeoutSeconds
responsesMaxDecompressedBodyBytes
requestIntervalMs
@@ -1265,6 +1266,13 @@ accountWarmupModel
切换账号或内部重试不重新计时,等待同时计入请求总超时。该时限不用于中断已开始的上游生成。
设置更新请求须包含这三个字段,新请求使用更新后的快照
`openaiGuardianReservedConcurrency`(默认 0,取值 0~4,294,967,295)为 Codex Guardian 自动审批保留账号并发,保存后对新请求生效。
Guardian 以 `subagent_kind` 或 `client_metadata.x-openai-subagent` 值 `guardian` 识别。取值 R 大于 0 时,
有限上限为 L 的账号对其他 OpenAI 请求只开放 `max(L − R, 1)` 个名额,Guardian 可用满 L;
开启账号排队后,Guardian 排在同账号已有 Guardian 之后、全部普通等待者之前,不受单账号排队上限约束,
仍受总等待容量与等待时限约束。不限并发的账号和关闭排队时的其余行为不变。
设置更新请求须包含该字段
`responsesMaxDecompressedBodyBytes` 是压缩 Responses HTTP 请求的解压输出上限,单位字节,默认
67108864(64 MiB)。必须为正整数,且可表示为进程平台的 `isize`;管理端以整数 MiB 编辑。
保存并发布成功后,新请求使用新值;已鉴权请求沿用原快照,无需重启。调高上限会增加大请求的内存占用,
+18 -2
View File
@@ -544,12 +544,12 @@ OpenAI 启用账号排队时,可等待的已有亲和绑定优先保留;子
### 并发等待
Core 的 `concurrency` 拥有中立的有界等待位置与 FIFO 唤醒,不依赖账号、路由或执行会话;
Core 的 `concurrency` 拥有中立的有界等待位置、优先级与同级 FIFO 唤醒,不依赖账号、路由或执行会话;
账号策略仅引用其中的等待配置值。执行准入与各 Provider 选择器分别持有等待队列,按 Key/账号隔离。
运行并发仍由 Redis 原子准入与账号租约裁决,等待队列只保存当前进程中的等待位置,不复制运行计数,
也不持久化正文或在重启后重放请求。每个等待 owner 最多容纳 1,024 个等待者作为资源兜底
新请求不能抢占已有等待队列;队首短周期重读容量,取消/完成等待通过 Drop 回收位置并唤醒后继。
新请求不能越过同级或更高优先级的等待者;队首短周期重读容量,取消/完成等待通过 Drop 回收位置并唤醒后继。
Provider 等待重试仍在同一次未发送准备阶段重新读取账号资格、容量、目录和冷却,不增加 attempt;
Key 的 RPM 在成功准入时才计数,金额限制在入队前及成功准入后检查。
账号并发与本地请求间隔可有限等待,权限、额度和上游冷却不能作为可等待容量。
@@ -557,6 +557,22 @@ Key 的 RPM 在成功准入时才计数,金额限制在入队前及成功准
切换等待层、账号或 Provider 不重置等待时限;没有发生排队时不启动该计时,也不据此中断已开始的上游生成。
队列满/超时属于本地容量拒绝,不作为上游限流反馈或 Provider 故障熔断证据
#### Guardian 审批优先
Guardian 预留值由运行设置持久化并随调度快照冻结,每个请求沿用开始时的设置。
Core 传递该值并拥有通用的并发上限与等待顺序,OpenAI Provider 负责识别请求类型并决定预留与优先级。
- OpenAI Provider 在选号前通过统一协议解析识别 `guardian`;原生发送与插件上游适配器使用相同分类。
请求头形成的协议上下文与正文 metadata 沿用编码器的既有优先顺序;其他协议不得仅凭同名字段获得该优先级。
适配器不因此继承原生会话亲和、续接或 WebSocket 约束,选号后的 attempt 中间件也不追溯改变已取得的租约。
- `openaiGuardianReservedConcurrency = R > 0` 时,有限账号上限 L 对普通请求为 `max(L - R, 1)`,
Guardian 可用满 L,资格判断、策略投影、容量观测和原子租约共用同一请求级上限,数值边界见 [运行设置 API](api.md#8-运行设置)
- 开启账号排队时,Guardian 在普通等待者之前、已有 Guardian 之后;同级 FIFO,不抢占已运行请求。
Guardian 可越过单账号等待数量上限,但仍受总等待容量、共享等待预算与截止时刻约束;取消及超时必须释放等待位置。
- 配置为 0 时不启用预留或优先级;账号排队关闭时不引入隐式队列。Key 层准入仍按原有限额与排队规则执行。
等待者重新选号及取得租约前复核当前队首,异步读取期间被插队的普通请求继续等待,不凭已有 ticket 跳过优先顺序。
### Client Key 限额与结算
日金额、七天金额、并发和 RPM 按 Client Key 跨账号、跨 Provider 合计,零表示不限;修改限额不重置已用金额。
+1
View File
@@ -29,6 +29,7 @@ export interface RuntimeSettings {
refreshMarginSeconds: number
refreshConcurrency: number
maxConcurrentPerAccount: number
openaiGuardianReservedConcurrency: number
requestIntervalMs: number
maxWaitingPerKey: number
maxWaitingPerAccount: number
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { BaseCard, BaseForm, BaseFormItem, BaseInput, BasePopover } from '@codex-proxy/ui'
import { CircleAlert, Gauge, Timer } from '@lucide/vue'
const maxConcurrentPerAccount = defineModel<string>('maxConcurrentPerAccount', { required: true })
const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: true })
const maxWaitingPerKey = defineModel<string>('maxWaitingPerKey', { required: true })
const maxWaitingPerAccount = defineModel<string>('maxWaitingPerAccount', { required: true })
const concurrencyWaitTimeoutSeconds = defineModel<string>('concurrencyWaitTimeoutSeconds', { required: true })
const openaiGuardianReservedConcurrency = defineModel<string>('openaiGuardianReservedConcurrency', { required: true })
</script>
<template>
<BaseCard title="并发策略">
<BaseForm class="max-w-6xl sm:grid-cols-2">
<BaseFormItem
label="默认账号并发上限"
description="账号未单独设置时使用的并发上限,0 表示不限制"
>
<BaseInput
v-model="maxConcurrentPerAccount"
aria-label="默认账号并发上限"
type="number"
min="0"
max="4294967295"
step="1"
>
<template #prefix>
<Gauge class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem
label="最小请求间隔"
description="控制同一账号两次调度之间的最小等待时间"
>
<BaseInput
v-model="requestIntervalMs"
aria-label="最小请求间隔(毫秒)"
type="number"
>
<template #prefix>
<Timer class="size-4" />
</template>
<template #suffix>
毫秒
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem label="密钥队列容量" description="每个客户端密钥允许等待的最大请求数,0 表示不排队">
<BaseInput v-model="maxWaitingPerKey" aria-label="密钥队列容量" type="number" min="0" max="1000" step="1" />
</BaseFormItem>
<BaseFormItem label="账号队列容量" description="每个上游账号允许等待的最大请求数,0 表示不排队">
<BaseInput v-model="maxWaitingPerAccount" aria-label="账号队列容量" type="number" min="0" max="1000" step="1" />
</BaseFormItem>
<BaseFormItem label="排队超时(秒)" description="密钥队列与账号队列共用的等待时限,从首次入队起计时,1~120 秒">
<BaseInput v-model="concurrencyWaitTimeoutSeconds" aria-label="排队超时(秒)" type="number" min="1" max="120" step="1" />
</BaseFormItem>
<BaseFormItem label="自动审批预留并发" description="为 Codex 自动审批预留每账号名额,0 表示关闭">
<template #label-extra>
<BasePopover class="-my-1" trigger="hover-click" placement="top-start">
<template #trigger="{ open }">
<button type="button" aria-label="自动审批预留并发说明" :aria-expanded="open" class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none">
<CircleAlert class="size-3.5" aria-hidden="true" />
</button>
</template>
<div class="max-w-72 space-y-2 px-3 py-2 text-cp-sm leading-relaxed text-cp-text-secondary">
<p>仅对 OpenAI 账号生效,普通请求可用并发会相应减少,至少保留 1 个名额,不限并发的账号不扣减</p>
<p>大于 0 且开启账号排队时,审批请求优先等待,可超过账号队列容量,仍受总等待容量、排队超时与密钥限额约束</p>
</div>
</BasePopover>
</template>
<BaseInput v-model="openaiGuardianReservedConcurrency" aria-label="自动审批预留并发" type="number" min="0" max="4294967295" step="1" />
</BaseFormItem>
</BaseForm>
</BaseCard>
</template>
@@ -1,23 +0,0 @@
<script setup lang="ts">
import { BaseCard, BaseForm, BaseFormItem, BaseInput } from '@codex-proxy/ui'
const maxWaitingPerKey = defineModel<string>('maxWaitingPerKey', { required: true })
const maxWaitingPerAccount = defineModel<string>('maxWaitingPerAccount', { required: true })
const concurrencyWaitTimeoutSeconds = defineModel<string>('concurrencyWaitTimeoutSeconds', { required: true })
</script>
<template>
<BaseCard title="并发队列">
<BaseForm class="max-w-6xl sm:grid-cols-2">
<BaseFormItem label="密钥队列容量" description="每个客户端密钥允许等待的最大请求数,0 表示不排队">
<BaseInput v-model="maxWaitingPerKey" aria-label="密钥队列容量" type="number" min="0" max="1000" step="1" />
</BaseFormItem>
<BaseFormItem label="账号队列容量" description="每个上游账号允许等待的最大请求数,0 表示不排队">
<BaseInput v-model="maxWaitingPerAccount" aria-label="账号队列容量" type="number" min="0" max="1000" step="1" />
</BaseFormItem>
<BaseFormItem label="排队超时(秒)" description="密钥队列与账号队列共用的等待时限,从首次入队起计时,1~120 秒">
<BaseInput v-model="concurrencyWaitTimeoutSeconds" aria-label="排队超时(秒)" type="number" min="1" max="120" step="1" />
</BaseFormItem>
</BaseForm>
</BaseCard>
</template>
@@ -1,50 +0,0 @@
<script setup lang="ts">
import { BaseCard, BaseForm, BaseFormItem, BaseInput } from '@codex-proxy/ui'
import { Gauge, Timer } from '@lucide/vue'
const maxConcurrentPerAccount = defineModel<string>('maxConcurrentPerAccount', { required: true })
const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: true })
</script>
<template>
<BaseCard title="并发控制">
<BaseForm class="max-w-6xl sm:grid-cols-2">
<BaseFormItem
label="默认账号并发上限"
description="账号未单独设置时使用的并发上限,0 表示不限制"
>
<BaseInput
v-model="maxConcurrentPerAccount"
aria-label="默认账号并发上限"
type="number"
min="0"
max="4294967295"
step="1"
>
<template #prefix>
<Gauge class="size-4" />
</template>
</BaseInput>
</BaseFormItem>
<BaseFormItem
label="最小请求间隔"
description="控制同一账号两次调度之间的最小等待时间"
>
<BaseInput
v-model="requestIntervalMs"
aria-label="最小请求间隔(毫秒)"
type="number"
>
<template #prefix>
<Timer class="size-4" />
</template>
<template #suffix>
毫秒
</template>
</BaseInput>
</BaseFormItem>
</BaseForm>
</BaseCard>
</template>
@@ -32,6 +32,7 @@ export function useSettingsForm() {
refreshMarginSeconds: null as number | null,
refreshConcurrency: null as number | null,
maxConcurrentPerAccount: null as number | null,
openaiGuardianReservedConcurrency: null as number | null,
requestIntervalMs: null as number | null,
maxWaitingPerKey: null as number | null,
maxWaitingPerAccount: null as number | null,
@@ -84,7 +85,7 @@ export function useSettingsForm() {
mappings.value = saved.value.mappings.map(row => ({ ...row }))
}
function numericModel(key: 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'requestIntervalMs' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') {
function numericModel(key: 'refreshMarginSeconds' | 'refreshConcurrency' | 'maxConcurrentPerAccount' | 'openaiGuardianReservedConcurrency' | 'requestIntervalMs' | 'maxWaitingPerKey' | 'maxWaitingPerAccount' | 'concurrencyWaitTimeoutSeconds' | 'responsesMaxDecompressedBodyMiB' | 'accountAutoFreezeThreshold' | 'accountAutoFreezeWindowSeconds' | 'accountAutoFreezeDurationSeconds') {
return computed({
get: () => (form[key] === null ? '' : String(form[key])),
set: (value: string) => {
@@ -101,6 +102,7 @@ export function useSettingsForm() {
const refreshMarginSecondsValue = numericModel('refreshMarginSeconds')
const refreshConcurrencyValue = numericModel('refreshConcurrency')
const maxConcurrentPerAccountValue = numericModel('maxConcurrentPerAccount')
const openaiGuardianReservedConcurrencyValue = numericModel('openaiGuardianReservedConcurrency')
const requestIntervalMsValue = numericModel('requestIntervalMs')
const maxWaitingPerKeyValue = numericModel('maxWaitingPerKey')
const maxWaitingPerAccountValue = numericModel('maxWaitingPerAccount')
@@ -126,6 +128,7 @@ export function useSettingsForm() {
form.refreshMarginSeconds = data.refreshMarginSeconds
form.refreshConcurrency = data.refreshConcurrency
form.maxConcurrentPerAccount = data.maxConcurrentPerAccount
form.openaiGuardianReservedConcurrency = data.openaiGuardianReservedConcurrency
form.requestIntervalMs = data.requestIntervalMs
form.maxWaitingPerKey = data.maxWaitingPerKey
form.maxWaitingPerAccount = data.maxWaitingPerAccount
@@ -211,8 +214,8 @@ export function useSettingsForm() {
const savedSettings = saved.value
if (saving.value || loading.value || !savedRequestLocation.value || !savedSettings)
return
const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, requestIntervalMs, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, responsesMaxDecompressedBodyMiB, accountAutoFreezeThreshold, accountAutoFreezeWindowSeconds, accountAutoFreezeDurationSeconds } = form
if (refreshMarginSeconds === null || refreshConcurrency === null || maxConcurrentPerAccount === null || requestIntervalMs === null || !rotationStrategy || maxWaitingPerKey === null || maxWaitingPerAccount === null || concurrencyWaitTimeoutSeconds === null) {
const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, openaiGuardianReservedConcurrency, requestIntervalMs, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, responsesMaxDecompressedBodyMiB, accountAutoFreezeThreshold, accountAutoFreezeWindowSeconds, accountAutoFreezeDurationSeconds } = form
if (refreshMarginSeconds === null || refreshConcurrency === null || maxConcurrentPerAccount === null || openaiGuardianReservedConcurrency === null || requestIntervalMs === null || !rotationStrategy || maxWaitingPerKey === null || maxWaitingPerAccount === null || concurrencyWaitTimeoutSeconds === null) {
toast.warning('请完整填写并发、队列、凭据刷新参数和调度策略')
return
}
@@ -220,6 +223,10 @@ export function useSettingsForm() {
toast.warning('默认账号并发上限应为 0~4294967295 的整数,0 表示不限制')
return
}
if (!Number.isInteger(openaiGuardianReservedConcurrency) || openaiGuardianReservedConcurrency < 0 || openaiGuardianReservedConcurrency > 4294967295) {
toast.warning('自动审批预留并发应为 0~4294967295 的整数,0 表示关闭')
return
}
if (responsesMaxDecompressedBodyMiB === null || !Number.isInteger(responsesMaxDecompressedBodyMiB) || responsesMaxDecompressedBodyMiB < 1
|| !Number.isSafeInteger(responsesMaxDecompressedBodyMiB * MIB)) {
toast.warning('Responses 解压上限应为有效的正整数(MiB)')
@@ -286,6 +293,7 @@ export function useSettingsForm() {
refreshMarginSeconds,
refreshConcurrency,
maxConcurrentPerAccount,
openaiGuardianReservedConcurrency,
requestIntervalMs,
maxWaitingPerKey,
maxWaitingPerAccount,
@@ -334,6 +342,7 @@ export function useSettingsForm() {
refreshMarginSecondsValue,
refreshConcurrencyValue,
maxConcurrentPerAccountValue,
openaiGuardianReservedConcurrencyValue,
requestIntervalMsValue,
maxWaitingPerKeyValue,
maxWaitingPerAccountValue,
+6 -7
View File
@@ -9,11 +9,10 @@ import AccountAutoFreezeCard from './components/AccountAutoFreezeCard.vue'
import AccountWarmupCard from './components/AccountWarmupCard.vue'
import SettingsBackupSection from './components/backup/SettingsBackupSection.vue'
import ClientProfileCard from './components/ClientProfileCard.vue'
import ConcurrencyPolicyCard from './components/ConcurrencyPolicyCard.vue'
import ModelAliasesCard from './components/ModelAliasesCard.vue'
import RequestLocationCard from './components/RequestLocationCard.vue'
import RequestQueueCard from './components/RequestQueueCard.vue'
import RotationStrategyCard from './components/RotationStrategyCard.vue'
import RuntimeSettingsCard from './components/RuntimeSettingsCard.vue'
import SettingsAccessSection from './components/SettingsAccessSection.vue'
import TokenRefreshCard from './components/TokenRefreshCard.vue'
import { useSettingsForm } from './composables/useSettingsForm'
@@ -61,6 +60,7 @@ const {
refreshMarginSecondsValue,
refreshConcurrencyValue,
maxConcurrentPerAccountValue,
openaiGuardianReservedConcurrencyValue,
requestIntervalMsValue,
maxWaitingPerKeyValue,
maxWaitingPerAccountValue,
@@ -144,10 +144,6 @@ watch(section, (value) => {
<fieldset v-show="section !== 'access'" :disabled="disabled" class="m-0 grid min-w-0 gap-5 border-0 p-0" aria-label="基础设置">
<template v-if="section === 'runtime'">
<RuntimeSettingsCard
v-model:max-concurrent-per-account="maxConcurrentPerAccountValue"
v-model:request-interval-ms="requestIntervalMsValue"
/>
<RotationStrategyCard
v-model="form.rotationStrategy"
v-model:smart-scheduling="form.smartScheduling"
@@ -155,10 +151,13 @@ watch(section, (value) => {
:disabled="disabled"
:options="rotationOptions"
/>
<RequestQueueCard
<ConcurrencyPolicyCard
v-model:max-concurrent-per-account="maxConcurrentPerAccountValue"
v-model:request-interval-ms="requestIntervalMsValue"
v-model:max-waiting-per-key="maxWaitingPerKeyValue"
v-model:max-waiting-per-account="maxWaitingPerAccountValue"
v-model:concurrency-wait-timeout-seconds="concurrencyWaitTimeoutSecondsValue"
v-model:openai-guardian-reserved-concurrency="openaiGuardianReservedConcurrencyValue"
/>
<AccountAutoFreezeCard
v-model:enabled="form.accountAutoFreezeEnabled"