Compare commits

...
5 Commits
Author SHA1 Message Date
ZyyandCodex cb397ae53e chore(release): v3.21.3
Co-authored-by: Codex <noreply@openai.com>
2026-10-08 12:12:34 +08:00
ZyyandCodex 63802f9b1a fix(usage): exclude cached tokens from displayed input
Reuse the fresh input calculation across usage records, token details and key usage metrics.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 11:43:26 +08:00
ZyyandCodex de599ed768 fix(openai): apply timezone overrides without content kinds
Recognize complete Codex environment contexts when custom providers omit
content classifications, while keeping explicit classifications authoritative.

Cover HTTP and WebSocket overrides and proxy location precedence, and
document timezone and client privacy boundaries.

Refs #366

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 11:16:31 +08:00
ZyyandCodex 3315ddbff6 fix(openai): isolate guardian concurrency from ordinary requests
Give Guardian requests a separate per-account lease pool and wait queue while preserving ordinary concurrency and shared request intervals. Use the ordinary pool when the Guardian limit is zero, and align scheduling, diagnostics, settings copy, and documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-08 10:38:52 +08:00
ZyyandCodex 6c583e341a fix(openai): reject invalid continuation replay before account selection
Check the recorded native continuation scope before selecting an account
or committing a session binding. Reuse the same scope validation after
middleware while preserving native connection checks and migration policy.

Cover ReplayOwner and ReplayAny with persisted provider metadata or a
connection-local pin, with and without an existing session binding.
Rejected replays must not acquire leases, write bindings or aliases, send
upstream requests, or redirect subsequent child requests.

Refs #364

Co-authored-by: Codex <noreply@openai.com>
2026-10-07 22:45:42 +08:00
37 changed files with 1060 additions and 376 deletions
@@ -90,18 +90,6 @@ 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 {
@@ -206,6 +206,7 @@ impl AccountSelectionPolicy {
/// Store 提供并发事实,Provider 叠加自己解释的额度事实;全部信号均可重建
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountRuntimeSignals {
/// 本次调度容量池的在途数,不合并其他独立池
pub in_flight: u32,
pub last_started_at: Option<SystemTime>,
pub quota_reset_at: Option<SystemTime>,
@@ -523,7 +524,7 @@ pub struct AccountSelectionContext {
pub round_robin_cursor: u64,
pub eligibility: AccountEligibilityPolicy,
pub account_scope: Option<std::sync::Arc<crate::account::scope::FrozenAccountScope>>,
/// 本请求不可占用的每账号预留并发名额,由 Provider 按请求类别决定;0 表示不预留
/// 本请求使用的独立预留池上限,由 Provider 按请求类别决定;0 表示使用普通账号池
pub reserved_concurrency: u32,
}
@@ -531,9 +532,11 @@ 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)
if self.reserved_concurrency > 0 {
AccountConcurrency::new(self.reserved_concurrency)
} else {
account.effective_concurrency(self.policy.max_concurrent_per_account())
}
}
}
@@ -723,7 +726,6 @@ impl AccountSelector {
let candidate = match context.policy.strategy() {
RotationStrategy::QuotaResetPriority => {
let default_concurrency = context.policy.max_concurrent_per_account();
eligible.sort_by(|left, right| {
(
left.signals.quota_reset_at.is_none(),
@@ -734,8 +736,8 @@ impl AccountSelector {
right.signals.quota_reset_at,
))
.then_with(|| {
capacity_utilization(left, default_concurrency)
.total_cmp(&capacity_utilization(right, default_concurrency))
capacity_utilization(left, context)
.total_cmp(&capacity_utilization(right, context))
})
.then_with(|| {
left.signals
@@ -751,13 +753,7 @@ impl AccountSelector {
let index = context.round_robin_cursor as usize % eligible.len();
eligible.get(index).copied()?
}
RotationStrategy::Smart => select_smart_candidate(
&eligible,
context.policy.max_concurrent_per_account(),
context.round_robin_cursor,
context.policy.smart_scheduling(),
context.now,
)?,
RotationStrategy::Smart => select_smart_candidate(&eligible, context)?,
RotationStrategy::Sticky => {
eligible.sort_by_key(|candidate| {
(
@@ -843,17 +839,7 @@ impl AccountSelector {
// 账号信号在队列锁外评分,锁内只读取实时队长并查表,避免扫描候选阻塞其他等待者
let scores = candidates
.iter()
.map(|candidate| {
(
candidate.account.id(),
smart_score(
candidate,
context.policy.max_concurrent_per_account(),
config,
context.now,
),
)
})
.map(|candidate| (candidate.account.id(), smart_score(candidate, context)))
.collect::<HashMap<_, _>>();
waiting
.wait_with_priority(keys, |key, count| {
@@ -914,13 +900,9 @@ const SMART_LATENCY_HALF_SCORE_MS: f64 = 10_000.0;
// 距重置一小时时得分减半;未知和已过期时间不提供重置奖励
const SMART_RESET_HALF_SCORE_SECONDS: f64 = 3_600.0;
fn capacity_utilization(
candidate: &AccountCandidate,
default_concurrency: AccountConcurrency,
) -> f64 {
candidate
.account
.effective_concurrency(default_concurrency)
fn capacity_utilization(candidate: &AccountCandidate, context: &AccountSelectionContext) -> f64 {
context
.concurrency_limit(&candidate.account)
.limit()
.map_or(0.0, |limit| {
f64::from(candidate.signals.in_flight) / f64::from(limit.get())
@@ -929,38 +911,29 @@ fn capacity_utilization(
fn select_smart_candidate<'a>(
candidates: &[&'a AccountCandidate],
default_concurrency: AccountConcurrency,
cursor: u64,
config: SmartSchedulingConfig,
now: SystemTime,
context: &AccountSelectionContext,
) -> Option<&'a AccountCandidate> {
let mut ranked = candidates
.iter()
.map(|candidate| {
(
*candidate,
smart_score(candidate, default_concurrency, config, now),
)
})
.map(|candidate| (*candidate, smart_score(candidate, context)))
.collect::<Vec<_>>();
let best_score = ranked
.iter()
.map(|(_, score)| *score)
.max_by(f64::total_cmp)?;
ranked.retain(|(_, score)| best_score - score <= config.score_tolerance());
ranked.retain(|(_, score)| {
best_score - score <= context.policy.smart_scheduling().score_tolerance()
});
// 轮换顺序保持稳定,避免分数轻微交错与 cursor 同步后仍反复命中同一账号
ranked.sort_unstable_by(|(left, _), (right, _)| left.account.id().cmp(right.account.id()));
let index = (cursor % ranked.len() as u64) as usize;
let index = (context.round_robin_cursor % ranked.len() as u64) as usize;
Some(ranked[index].0)
}
pub(crate) fn smart_score(
candidate: &AccountCandidate,
default_concurrency: AccountConcurrency,
config: SmartSchedulingConfig,
now: SystemTime,
) -> f64 {
let load = 1.0 - capacity_utilization(candidate, default_concurrency).clamp(0.0, 1.0);
pub(crate) fn smart_score(candidate: &AccountCandidate, context: &AccountSelectionContext) -> f64 {
let config = context.policy.smart_scheduling();
let now = context.now;
let load = 1.0 - capacity_utilization(candidate, context).clamp(0.0, 1.0);
let quota = candidate
.signals
.quota_remaining_rank
@@ -41,17 +41,14 @@ impl TraceContext {
"blocker": AccountSelector.scheduling_blocker(candidate, context)
.map(|blocker| format!("{blocker:?}")),
"inFlight": signals.in_flight,
"concurrencyLimit": candidate.account
.effective_concurrency(context.policy.max_concurrent_per_account()).get(),
"concurrencyLimit": context.concurrency_limit(&candidate.account).get(),
"quotaRemainingBasisPoints": signals.quota_remaining_rank,
"quotaResetAtUnixMs": signals.quota_reset_at.and_then(|reset|
reset.duration_since(std::time::UNIX_EPOCH).ok()
).map(|duration| duration.as_millis()),
"failureRateBasisPoints": signals.failure_rate_basis_points,
"firstOutputLatencyMs": signals.first_output_latency_ms,
"smartScore": smart.then(|| smart_score(
candidate, context.policy.max_concurrent_per_account(), context.policy.smart_scheduling(), context.now
)),
"smartScore": smart.then(|| smart_score(candidate, context)),
})
})
.collect::<Vec<_>>();
@@ -9,6 +9,14 @@ use crate::{
use futures::future::BoxFuture;
use std::{collections::BTreeMap, fmt, num::NonZeroU32, time::Duration};
/// 账号内独立计数的调度容量池;Provider 决定请求归属,Store 只隔离租约
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ProviderConcurrencyPool {
#[default]
Shared,
Reserved,
}
/// 一个 Provider 的完整可重建调度状态
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderSchedulingState {
@@ -46,6 +54,7 @@ pub struct ProviderSchedulingLeaseRequest {
account_id: ProviderAccountId,
credential_revision: CredentialRevision,
max_concurrent: AccountConcurrency,
concurrency_pool: ProviderConcurrencyPool,
request_interval: Duration,
deadline: crate::lifecycle::Deadline,
cancellation: crate::lifecycle::CancellationToken,
@@ -66,6 +75,7 @@ impl ProviderSchedulingLeaseRequest {
account_id,
credential_revision,
max_concurrent: max_concurrent.into(),
concurrency_pool: ProviderConcurrencyPool::Shared,
request_interval,
deadline: deadline.into(),
cancellation: crate::lifecycle::CancellationToken::new(),
@@ -78,6 +88,17 @@ impl ProviderSchedulingLeaseRequest {
self
}
#[must_use]
pub const fn with_concurrency_pool(mut self, pool: ProviderConcurrencyPool) -> Self {
self.concurrency_pool = pool;
self
}
#[must_use]
pub const fn concurrency_pool(&self) -> ProviderConcurrencyPool {
self.concurrency_pool
}
#[must_use]
pub fn cancellation(&self) -> crate::lifecycle::CancellationToken {
self.cancellation.clone()
@@ -145,11 +166,13 @@ pub enum ProviderLeaseRequest {
}
pub trait ProviderLeasePort: Send + Sync {
/// 按目标容量池读取在途数,账号最小请求间隔仍跨池共享
fn load_state<'a>(
&'a self,
client_api_key_id: &'a ClientApiKeyId,
provider_kind: &'a ProviderKind,
accounts: &'a [ProviderAccountId],
pool: ProviderConcurrencyPool,
) -> BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>>;
fn try_acquire(
@@ -157,7 +180,7 @@ pub trait ProviderLeasePort: Send + Sync {
request: ProviderLeaseRequest,
) -> BoxFuture<'_, Result<ProviderLeaseAcquisition, ProviderStoreError>>;
/// 读取指定账号当前的在途请求数;只用于容量熔断的峰值证据,
/// 读取指定账号普通池的在途请求数;只用于容量熔断的峰值证据,
/// 支持租约信号的存储实现覆盖,否则视为不可观测(空映射)
fn account_in_flight<'a>(
&'a self,
@@ -4,9 +4,9 @@ mod error;
pub use error::{ProviderStoreError, ProviderStoreErrorKind};
mod lease;
pub use lease::{
ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort, ProviderLeaseRequest,
ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest, ProviderSchedulingLeaseRequest,
ProviderSchedulingState,
ProviderConcurrencyPool, ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort,
ProviderLeaseRequest, ProviderRefreshCapacityRequest, ProviderRefreshLeaseRequest,
ProviderSchedulingLeaseRequest, ProviderSchedulingState,
};
mod affinity;
pub use affinity::{
@@ -15,13 +15,12 @@ use std::time::{Duration, SystemTime};
use serde_json::{Map, Value};
use gateway_core::account::{
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,
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,
};
use gateway_core::routing::{
ClientRoutingScope, FrozenAccountScope, ProviderKind, RuntimeAccount, RuntimeAccountDirectory,
@@ -1099,40 +1098,57 @@ fn due_probe_freeze_blocks_scheduling_and_remains_visible_as_rate_limited() {
}
#[test]
fn reserved_concurrency_only_blocks_requests_that_cannot_use_the_reserved_slots() {
fn reserved_concurrency_uses_its_own_limit_without_reducing_normal_capacity() {
let normal = context(RotationStrategy::Smart);
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());
assert!(AccountSelector.select(&candidates, &normal).is_some());
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);
let mut reserved = normal.clone();
reserved.reserved_concurrency = 3;
let candidates = [candidate_with_concurrency("acct_reserved", 2, 5)];
assert!(AccountSelector.select(&candidates, &reserved).is_some());
let capacity = AccountSelector
.capacity_snapshot(&candidates, &reserved)
.unwrap();
assert_eq!((capacity.used_slots(), capacity.total_slots()), (2, 3));
let full = [candidate_with_concurrency("acct_reserved", 3, 5)];
assert!(AccountSelector.select(&full, &reserved).is_none());
assert_eq!(AccountSelector.wait_candidates(&full, &reserved).len(), 1);
assert!(AccountSelector.select(&full, &normal).is_some());
// 独立额度可以大于账号普通上限,极值不能回绕成无限并发
reserved.reserved_concurrency = u32::MAX;
assert_eq!(
AccountConcurrency::Unlimited.excluding_reserved(2),
AccountConcurrency::Unlimited
reserved.concurrency_limit(&candidates[0].account).get(),
u32::MAX
);
assert_eq!(normal.concurrency_limit(&candidates[0].account).get(), 5);
}
#[test]
fn reserved_capacity_stays_bounded_when_normal_concurrency_is_unlimited() {
let candidates = [candidate("acct_unlimited", 3, None)];
let mut normal = context(RotationStrategy::Smart);
normal.policy = AccountSelectionPolicy::new(
RotationStrategy::Smart,
gateway_core::account::AccountConcurrency::Unlimited,
Duration::ZERO,
);
assert!(AccountSelector.select(&candidates, &normal).is_some());
assert!(
AccountSelector
.capacity_snapshot(&candidates, &normal)
.is_none()
);
let mut reserved = normal;
reserved.reserved_concurrency = 3;
assert!(AccountSelector.select(&candidates, &reserved).is_none());
let capacity = AccountSelector
.capacity_snapshot(&candidates, &reserved)
.unwrap();
assert_eq!((capacity.used_slots(), capacity.total_slots()), (3, 3));
}
@@ -13,6 +13,31 @@ use gateway_core::concurrency::{
use super::{candidate_with_concurrency, context, weighted_candidate};
#[test]
fn reserved_pool_scoring_and_trace_use_its_capacity_instead_of_the_normal_limit() {
let mut context = context(RotationStrategy::Smart);
context.reserved_concurrency = 5;
context.policy = context.policy.with_smart_scheduling(
SmartSchedulingConfig::new([1.0, 0.0, 0.0, 0.0, 0.0, 0.0], false).unwrap(),
);
let candidates = [
candidate_with_concurrency("acct_large_normal", 2, 100),
candidate_with_concurrency("acct_small_normal", 1, 1),
];
let selection = AccountSelector.select(&candidates, &context).unwrap();
assert_eq!(
selection.candidate().account.id().as_str(),
"acct_small_normal"
);
let trace = gateway_core::diagnostics::TraceContext::new("req_reserved_scores");
trace.account_selection(&candidates, &context, Some(&selection));
let snapshot = trace.snapshot().unwrap();
let selected = &snapshot["events"][0]["data"]["candidates"][0];
assert_eq!(selected["concurrencyLimit"], 5);
assert_eq!(selected["inFlight"], 1);
assert_eq!(selected["smartScore"], 0.8);
}
#[test]
fn smart_config_rejects_invalid_weights_and_incomplete_wire_values() {
for value in [-0.1, 10.1, 0.01, f64::NAN, f64::INFINITY] {
@@ -4,8 +4,8 @@
"version": 3,
"replacement_version": 4,
"introduced_in": "3.20.0",
"last_counted_in": "3.21.2",
"remaining_releases": 4,
"last_counted_in": "3.21.3",
"remaining_releases": 3,
"migration": "将执行设置和来源中的 disable_fast 改为 fast_mode,使用 default、enabled、disabled 三态,并将 middleware 声明升级到 v4"
},
{
@@ -13,8 +13,8 @@
"version": 1,
"replacement_version": 2,
"introduced_in": "3.20.0",
"last_counted_in": "3.21.2",
"remaining_releases": 4,
"last_counted_in": "3.21.3",
"remaining_releases": 3,
"migration": "使用新版 SDK 读取 fast_mode 三态,并将 upstream_adapter 声明升级到 v2"
}
]
@@ -12,7 +12,7 @@ use gateway_core::account::{AccountRuntimeSignals, ProviderAccountId};
use gateway_core::lifecycle::REQUEST_LEASE_TTL;
use gateway_core::policy::ClientApiKeyId;
use gateway_core::provider_ports::{
ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
ProviderConcurrencyPool, ProviderLeaseAcquisition, ProviderLeasePort, ProviderLeaseRequest,
ProviderRefreshCapacityRequest, ProviderSchedulingLeaseRequest, ProviderSchedulingState,
ProviderStoreError, ProviderStoreErrorKind,
};
@@ -128,6 +128,7 @@ return tostring(cursor - 1)
pub enum CredentialLeaseScope {
Provider,
ProviderAccount,
ProviderAccountReserved,
OAuthRefreshCapacity,
OAuthRefresh,
ProviderTask,
@@ -138,11 +139,19 @@ impl CredentialLeaseScope {
match self {
Self::Provider => "provider",
Self::ProviderAccount => "account",
Self::ProviderAccountReserved => "account-reserved",
Self::OAuthRefreshCapacity => "refresh-capacity",
Self::OAuthRefresh => "refresh",
Self::ProviderTask => "task",
}
}
const fn scheduling(pool: ProviderConcurrencyPool) -> Self {
match pool {
ProviderConcurrencyPool::Shared => Self::ProviderAccount,
ProviderConcurrencyPool::Reserved => Self::ProviderAccountReserved,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -334,6 +343,7 @@ pub trait CredentialLeaseRepository: Send + Sync {
request: &CredentialLeaseRequest,
grant: &CredentialLeaseGrant,
) -> StoreResult<bool>;
/// 管理端容量与自适应并发观测只统计普通池,不把审批占用投影为普通并发压力
async fn credential_runtime_signals(
&self,
resource_ids: &[String],
@@ -395,10 +405,16 @@ impl RedisCredentialLeaseRepository {
let fingerprint = resource_fingerprint("credential lease", &request.resource_id)?;
let tag = format!("{{{fingerprint}}}");
let prefix = format!("{}:lease:{}:{tag}", self.namespace, request.scope.as_str());
// 两个容量池独立计数,但账号最小请求间隔仍由同一时间戳原子裁决
let interval_prefix = if request.scope == CredentialLeaseScope::ProviderAccountReserved {
format!("{}:lease:account:{tag}", self.namespace)
} else {
prefix.clone()
};
Ok([
format!("{prefix}:active"),
format!("{prefix}:fence"),
format!("{prefix}:last-started"),
format!("{interval_prefix}:last-started"),
])
}
@@ -472,9 +488,29 @@ impl RedisCredentialLeaseRepository {
})
}
async fn load_signal(&self, resource_id: String) -> StoreResult<CredentialRuntimeSignal> {
async fn runtime_signals(
&self,
resource_ids: &[String],
pool: ProviderConcurrencyPool,
) -> StoreResult<Vec<CredentialRuntimeSignal>> {
for resource_id in resource_ids {
require_nonempty("credential runtime signal", "resource_id", resource_id)?;
}
let mut signals = Vec::with_capacity(resource_ids.len());
for ids in resource_ids.chunks(super::ACCOUNT_STATE_READ_CONCURRENCY) {
let batch = join_all(ids.iter().cloned().map(|id| self.load_signal(id, pool))).await;
signals.extend(batch.into_iter().collect::<StoreResult<Vec<_>>>()?);
}
Ok(signals)
}
async fn load_signal(
&self,
resource_id: String,
pool: ProviderConcurrencyPool,
) -> StoreResult<CredentialRuntimeSignal> {
let request = CredentialLeaseRequest {
scope: CredentialLeaseScope::ProviderAccount,
scope: CredentialLeaseScope::scheduling(pool),
resource_id: resource_id.clone(),
owner_id: "signal-reader".to_owned(),
ttl: Duration::from_secs(1),
@@ -539,6 +575,7 @@ impl RedisProviderLeaseCoordinator {
async fn load_signals(
&self,
accounts: &[ProviderAccountId],
pool: ProviderConcurrencyPool,
) -> Result<BTreeMap<ProviderAccountId, AccountRuntimeSignals>, ProviderStoreError> {
let ids = accounts
.iter()
@@ -546,7 +583,7 @@ impl RedisProviderLeaseCoordinator {
.collect::<Vec<_>>();
let signals = self
.repository
.credential_runtime_signals(&ids)
.runtime_signals(&ids, pool)
.await
.map_err(|source| crate::provider_unavailable("load scheduling signals", source))?;
signals
@@ -588,7 +625,7 @@ impl RedisProviderLeaseCoordinator {
let acquisition = self
.repository
.try_acquire_bounded_lease(&CredentialBoundedLeaseRequest {
scope: CredentialLeaseScope::ProviderAccount,
scope: CredentialLeaseScope::scheduling(request.concurrency_pool()),
resource_id: request.account_id().as_str().to_owned(),
owner_id: self.owner_id("request"),
max_concurrent: request.max_concurrent().get(),
@@ -646,9 +683,10 @@ impl ProviderLeasePort for RedisProviderLeaseCoordinator {
client_api_key_id: &'a ClientApiKeyId,
provider_kind: &'a ProviderKind,
accounts: &'a [ProviderAccountId],
pool: ProviderConcurrencyPool,
) -> futures::future::BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
Box::pin(async move {
let signals = self.load_signals(accounts).await?;
let signals = self.load_signals(accounts, pool).await?;
let round_robin_cursor = self
.next_scheduling_cursor(client_api_key_id, provider_kind)
.await?;
@@ -782,15 +820,8 @@ impl CredentialLeaseRepository for RedisCredentialLeaseRepository {
&self,
resource_ids: &[String],
) -> StoreResult<Vec<CredentialRuntimeSignal>> {
for resource_id in resource_ids {
require_nonempty("credential runtime signal", "resource_id", resource_id)?;
}
let mut signals = Vec::with_capacity(resource_ids.len());
for ids in resource_ids.chunks(super::ACCOUNT_STATE_READ_CONCURRENCY) {
let batch = join_all(ids.iter().cloned().map(|id| self.load_signal(id))).await;
signals.extend(batch.into_iter().collect::<StoreResult<Vec<_>>>()?);
}
Ok(signals)
self.runtime_signals(resource_ids, ProviderConcurrencyPool::Shared)
.await
}
async fn try_acquire_bounded_lease(
@@ -34,95 +34,223 @@ fn only_account_scheduling_leases_allow_unlimited_concurrency() {
}
#[tokio::test]
async fn maintained_scheduling_lease_survives_initial_ttl_and_releases_on_drop() {
async fn reserved_and_normal_leases_enforce_independent_atomic_limits_and_release() {
let Some((repository, _, _)) = repository().await else {
return;
};
let mut request = scheduling_request("acct_long_request", "worker-long", 1, Duration::ZERO);
request.ttl = Duration::from_secs(1);
let lease = acquired(
let normal = scheduling_request("acct_independent", "normal", 2, Duration::ZERO);
let reserved = CredentialBoundedLeaseRequest {
scope: CredentialLeaseScope::ProviderAccountReserved,
max_concurrent: 3,
..normal.clone()
};
let attempts = futures::future::join_all((0..20).map(|index| {
let request = if index % 2 == 0 {
normal.clone()
} else {
reserved.clone()
};
let repository = &repository;
async move {
(
request.scope,
repository
.try_acquire_bounded_lease(&request)
.await
.unwrap(),
)
}
}))
.await;
let mut normal_leases = Vec::new();
let mut reserved_leases = Vec::new();
for (scope, acquisition) in attempts {
if let CredentialBoundedLeaseAcquisition::Acquired(lease) = acquisition {
if scope == CredentialLeaseScope::ProviderAccount {
normal_leases.push(lease);
} else {
reserved_leases.push(lease);
}
}
}
assert_eq!((normal_leases.len(), reserved_leases.len()), (2, 3));
assert_eq!(
repository
.try_acquire_bounded_lease(&request)
.credential_runtime_signals(std::slice::from_ref(&normal.resource_id))
.await
.unwrap()[0]
.in_flight,
2
);
reserved_leases.pop().unwrap().release().await.unwrap();
assert!(matches!(
repository.try_acquire_bounded_lease(&normal).await.unwrap(),
CredentialBoundedLeaseAcquisition::Busy { .. }
));
reserved_leases.push(acquired(
repository
.try_acquire_bounded_lease(&reserved)
.await
.unwrap(),
);
let cancellation = gateway_core::lifecycle::CancellationToken::new();
let lease = lease
.maintain(Default::default(), cancellation.clone())
.unwrap();
tokio::time::sleep(Duration::from_millis(1_100)).await;
assert!(!cancellation.is_cancelled());
));
normal_leases.pop().unwrap().release().await.unwrap();
assert!(matches!(
repository
.try_acquire_bounded_lease(&request)
.try_acquire_bounded_lease(&reserved)
.await
.unwrap(),
CredentialBoundedLeaseAcquisition::Busy { .. }
));
drop(lease);
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let CredentialBoundedLeaseAcquisition::Acquired(replacement) = repository
normal_leases.push(acquired(
repository.try_acquire_bounded_lease(&normal).await.unwrap(),
));
for lease in normal_leases.into_iter().chain(reserved_leases) {
lease.release().await.unwrap();
}
assert_eq!(
repository
.credential_runtime_signals(std::slice::from_ref(&normal.resource_id))
.await
.unwrap()[0]
.in_flight,
0
);
}
#[tokio::test]
async fn independent_capacity_pools_preserve_the_shared_account_request_interval() {
let Some((repository, _, _)) = repository().await else {
return;
};
for first_reserved in [false, true] {
let normal = scheduling_request(
&format!("acct_interval_{first_reserved}"),
"worker",
2,
Duration::from_secs(30),
);
let reserved = CredentialBoundedLeaseRequest {
scope: CredentialLeaseScope::ProviderAccountReserved,
..normal.clone()
};
let (first, second) = if first_reserved {
(&reserved, &normal)
} else {
(&normal, &reserved)
};
acquired(repository.try_acquire_bounded_lease(first).await.unwrap())
.release()
.await
.unwrap();
assert!(
matches!(repository.try_acquire_bounded_lease(second).await.unwrap(), CredentialBoundedLeaseAcquisition::Busy { retry_after: Some(value) } if value > Duration::ZERO)
);
}
}
#[tokio::test]
async fn maintained_scheduling_lease_survives_initial_ttl_and_releases_on_drop() {
for scope in [
CredentialLeaseScope::ProviderAccount,
CredentialLeaseScope::ProviderAccountReserved,
] {
let Some((repository, _, _)) = repository().await else {
return;
};
let mut request = scheduling_request("acct_long_request", "worker-long", 1, Duration::ZERO);
request.scope = scope;
request.ttl = Duration::from_secs(1);
let lease = acquired(
repository
.try_acquire_bounded_lease(&request)
.await
.unwrap()
{
replacement.release().await.unwrap();
break;
.unwrap(),
);
let cancellation = gateway_core::lifecycle::CancellationToken::new();
let lease = lease
.maintain(Default::default(), cancellation.clone())
.unwrap();
tokio::time::sleep(Duration::from_millis(1_100)).await;
assert!(!cancellation.is_cancelled());
assert!(matches!(
repository
.try_acquire_bounded_lease(&request)
.await
.unwrap(),
CredentialBoundedLeaseAcquisition::Busy { .. }
));
drop(lease);
tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let CredentialBoundedLeaseAcquisition::Acquired(replacement) = repository
.try_acquire_bounded_lease(&request)
.await
.unwrap()
{
replacement.release().await.unwrap();
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("dropped request releases its slot");
})
.await
.expect("dropped request releases its slot");
}
}
#[tokio::test]
async fn maintained_scheduling_lease_does_not_resurrect_a_lost_slot() {
let Some((repository, _, _)) = repository().await else {
return;
};
let request = scheduling_request("acct_lost_request", "worker-lost", 1, Duration::ZERO);
let lease = acquired(
for scope in [
CredentialLeaseScope::ProviderAccount,
CredentialLeaseScope::ProviderAccountReserved,
] {
let Some((repository, _, _)) = repository().await else {
return;
};
let mut request = scheduling_request("acct_lost_request", "worker-lost", 1, Duration::ZERO);
request.scope = scope;
let lease = acquired(
repository
.try_acquire_bounded_lease(&request)
.await
.unwrap(),
);
let grant = lease.grant().unwrap().clone();
let raw = CredentialLeaseRequest {
scope: request.scope,
resource_id: request.resource_id.clone(),
owner_id: request.owner_id.clone(),
ttl: request.ttl,
};
repository
.try_acquire_bounded_lease(&request)
.release_credential_lease(&raw, &grant)
.await
.unwrap(),
);
let grant = lease.grant().unwrap().clone();
let raw = CredentialLeaseRequest {
scope: request.scope,
resource_id: request.resource_id.clone(),
owner_id: request.owner_id.clone(),
ttl: request.ttl,
};
repository
.release_credential_lease(&raw, &grant)
.await
.unwrap();
let cancellation = gateway_core::lifecycle::CancellationToken::new();
let lease = lease
.maintain(Default::default(), cancellation.clone())
.unwrap();
tokio::time::timeout(Duration::from_secs(2), cancellation.cancelled())
.await
.expect("lost lease cancels the request");
let replacement = acquired(
repository
.try_acquire_bounded_lease(&request)
.unwrap();
let cancellation = gateway_core::lifecycle::CancellationToken::new();
let lease = lease
.maintain(Default::default(), cancellation.clone())
.unwrap();
tokio::time::timeout(Duration::from_secs(2), cancellation.cancelled())
.await
.unwrap(),
);
drop(lease);
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(matches!(
repository
.try_acquire_bounded_lease(&request)
.await
.unwrap(),
CredentialBoundedLeaseAcquisition::Busy { .. }
));
replacement.release().await.unwrap();
.expect("lost lease cancels the request");
let replacement = acquired(
repository
.try_acquire_bounded_lease(&request)
.await
.unwrap(),
);
drop(lease);
tokio::time::sleep(Duration::from_millis(20)).await;
assert!(matches!(
repository
.try_acquire_bounded_lease(&request)
.await
.unwrap(),
CredentialBoundedLeaseAcquisition::Busy { .. }
));
replacement.release().await.unwrap();
}
}
#[tokio::test]
@@ -16,10 +16,10 @@ use gateway_core::account::{
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,
ProviderSchedulingLeaseRequest, ProviderSessionAffinityKey, ProviderSessionAffinityPort,
ProviderSessionBinding, ProviderSessionExclusionPort, ProviderSessionExclusions,
ProviderStoreError,
ProviderConcurrencyPool, ProviderLeaseAcquisition, ProviderLeaseGuard, ProviderLeasePort,
ProviderLeaseRequest, ProviderSchedulingLeaseRequest, ProviderSessionAffinityKey,
ProviderSessionAffinityPort, ProviderSessionBinding, ProviderSessionExclusionPort,
ProviderSessionExclusions, ProviderStoreError,
};
use gateway_core::routing::ProviderKind;
use secrecy::ExposeSecret;
@@ -114,7 +114,7 @@ struct CredentialSelectionInput<'a> {
attempt: &'a AttemptContext,
session_affinity_key: Option<&'a ProviderSessionAffinityKey>,
session_affinity_observation: Option<&'a CodexSessionAffinity>,
/// Codex Guardian 自动审批请求;仅在配置了预留名额时获得预留与队列优先
/// Codex Guardian 自动审批请求;仅在配置独立额度时使用审批容量池
guardian: bool,
}
@@ -126,6 +126,7 @@ pub(crate) struct CodexCyberPolicyScope {
pub struct CodexCredentialSelector {
waiting: ConcurrencyWaitQueue<ProviderAccountId>,
reserved_waiting: ConcurrencyWaitQueue<ProviderAccountId>,
provider_kind: ProviderKind,
repository: CodexCredentialRepository,
leases: Arc<dyn ProviderLeasePort>,
@@ -285,6 +286,7 @@ impl CodexCredentialSelector {
cookie_policy,
risk_recovery: Mutex::new(HashMap::new()),
waiting: ConcurrencyWaitQueue::default(),
reserved_waiting: ConcurrencyWaitQueue::default(),
account_feedback,
}
}
@@ -465,15 +467,24 @@ impl CodexCredentialSelector {
queue_policy.timeout = Duration::from_secs(30);
}
}
// 预留名额只对普通请求生效;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 reserved_concurrency = if prioritized { reserve } else { 0 };
let concurrency_pool = if prioritized {
ProviderConcurrencyPool::Reserved
} else {
ProviderConcurrencyPool::Shared
};
let mut waiting = CapacityWait::new(
&self.waiting,
if prioritized {
&self.reserved_waiting
} else {
&self.waiting
},
queue_policy,
request.attempt.deadline().at(),
request.attempt.concurrency_wait_budget(),
@@ -619,6 +630,7 @@ impl CodexCredentialSelector {
request.attempt.client_api_key_ref(),
&self.provider_kind,
&account_ids,
concurrency_pool,
)
.await?;
let round_robin_cursor = scheduling.round_robin_cursor();
@@ -877,6 +889,7 @@ impl CodexCredentialSelector {
policy.request_interval(),
request.attempt.deadline(),
)
.with_concurrency_pool(concurrency_pool)
.with_cancellation(request.attempt.cancellation().clone()),
))
.await?
@@ -176,6 +176,54 @@ struct PreparedGenerateRequest {
cyber_policy_session_key: Option<ProviderSessionAffinityKey>,
}
impl PreparedGenerateRequest {
fn checked_native_continuation_scope(
&self,
context: &AttemptContext,
) -> Result<PreviousResponseScope, ProviderError> {
let pin = context.continuation().and_then(ContinuationBinding::pinned);
let scope = match self
.previous_session
.as_ref()
.map(|state| state.continuation_scope)
{
Some(OpenAiContinuationScope::Persisted) => PreviousResponseScope::Persisted,
Some(OpenAiContinuationScope::ConnectionLocal) => {
PreviousResponseScope::ConnectionLocal
}
Some(OpenAiContinuationScope::ReplayRequired) => PreviousResponseScope::ExternalUnknown,
None => match pin.map(|pin| pin.scope()) {
Some(NativeContinuationScope::Persisted) => PreviousResponseScope::Persisted,
Some(NativeContinuationScope::ConnectionLocal) => {
PreviousResponseScope::ConnectionLocal
}
None => PreviousResponseScope::ExternalUnknown,
},
};
// 已知不能脱离原连接的重放必须在选号前拒绝,避免失败请求先改写共享绑定
// Native 仍交给连接池核对原连接;此处不推测连接是否存活或上游状态是否可用
if pin.is_some()
&& matches!(
context.continuation_attempt(),
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny
)
&& scope == PreviousResponseScope::ConnectionLocal
{
tracing::warn!(
request_id = context.request_id().as_str(),
attempt_index = context.attempt_index().get(),
continuation_scope = "connection_local",
continuation_attempt = context.continuation_attempt().as_str(),
continuation_recovery_disposition = "client_replay_required",
continuation_recovery_action = "stop_proxy_recovery",
"OpenAI connection-local continuation replay was rejected before send"
);
return Err(continuation_replay_required_error("scope_unavailable"));
}
Ok(scope)
}
}
struct SelectedGenerate {
lease: CodexCredentialLease,
account_selection_wait_ms: u64,
@@ -578,6 +626,9 @@ impl Provider for CodexProvider {
let preselection = upstream
.filter(|_| adapter.is_none())
.map(|upstream| self.prepare_generate_request(generate, upstream, &context));
if let Some(prepared) = preselection.as_ref() {
prepared.checked_native_continuation_scope(&context)?;
}
let (selection_session_affinity, selection_cyber_policy_key, requires_websocket) =
preselection.map_or((None, None, false), |prepared| {
let requires_websocket =
@@ -715,6 +766,7 @@ impl CodexProvider {
&middleware_headers,
);
let processed = self.prepare_generate_request(&generate, upstream, &context);
let native_scope = processed.checked_native_continuation_scope(&context)?;
let mut upstream_request = processed.upstream;
let session_transport_key =
derive_codex_transport_key(&upstream_request, context.client_api_key_ref());
@@ -788,42 +840,6 @@ impl CodexProvider {
{
match continuation {
ContinuationBinding::Pinned(continuation) => {
let native_scope = match previous_session
.as_ref()
.map(|state| state.continuation_scope)
{
Some(OpenAiContinuationScope::Persisted) => {
PreviousResponseScope::Persisted
}
Some(OpenAiContinuationScope::ConnectionLocal) => {
PreviousResponseScope::ConnectionLocal
}
Some(OpenAiContinuationScope::ReplayRequired) => {
PreviousResponseScope::ExternalUnknown
}
None => match continuation.scope() {
NativeContinuationScope::Persisted => PreviousResponseScope::Persisted,
NativeContinuationScope::ConnectionLocal => {
PreviousResponseScope::ConnectionLocal
}
},
};
if matches!(
context.continuation_attempt(),
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny
) && native_scope == PreviousResponseScope::ConnectionLocal
{
tracing::warn!(
request_id = context.request_id().as_str(),
attempt_index = context.attempt_index().get(),
continuation_scope = "connection_local",
continuation_attempt = context.continuation_attempt().as_str(),
continuation_recovery_disposition = "client_replay_required",
continuation_recovery_action = "stop_proxy_recovery",
"OpenAI connection-local continuation replay was rejected before send"
);
return Err(continuation_replay_required_error("scope_unavailable"));
}
let previous_response_scope = match context.continuation_attempt() {
ContinuationAttempt::Native => native_scope,
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny => {
@@ -130,7 +130,7 @@ pub(crate) fn align_structured_location_fields(
now: DateTime<Utc>,
location: &CodexRequestLocation,
) {
// 只改写带环境标记的日期和时区;epoch 时间戳保持绝对时间原值
// 只改写完整环境上下文的日期和时区;epoch 时间戳保持绝对时间原值
let current_date = now
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
@@ -169,11 +169,10 @@ fn align_environment_context(item: &mut Value, current_date: &str, timezone: &st
return;
};
for (index, part) in content.iter_mut().enumerate() {
if content_kinds
.as_ref()
.and_then(|kinds| kinds.get(index))
.and_then(Option::as_deref)
!= Some(ENVIRONMENT_CONTEXT_CONTENT_KIND)
// 官方自定义 Provider 会移除内容分类,缺省时仍按完整环境上下文识别
// 已有分类保持权威,不能覆盖显式标为普通文本或其他上下文的内容
if let Some(kinds) = &content_kinds
&& kinds.get(index).and_then(Option::as_deref) != Some(ENVIRONMENT_CONTEXT_CONTENT_KIND)
{
continue;
}
@@ -2545,6 +2545,7 @@ mod errors {
_: &'a ClientApiKeyId,
_: &'a ProviderKind,
_: &'a [ProviderAccountId],
_pool: gateway_core::provider_ports::ProviderConcurrencyPool,
) -> BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
panic!("manual refresh does not use scheduling leases")
}
@@ -124,6 +124,7 @@ impl ProviderLeasePort for RefreshLeases {
_: &'a ClientApiKeyId,
_: &'a ProviderKind,
_: &'a [ProviderAccountId],
_pool: gateway_core::provider_ports::ProviderConcurrencyPool,
) -> BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
Box::pin(async { panic!("scheduled credential refresh does not load scheduling state") })
}
@@ -1,4 +1,4 @@
//! 验证 OpenAI 请求等待账号容量与 Guardian 预留槽位
//! 验证 OpenAI 请求等待账号容量与 Guardian 独立容量池
use futures::FutureExt;
use gateway_core::account::AccountRuntimeSignals;
@@ -123,7 +123,11 @@ async fn queued_root_and_new_child_both_observe_the_same_account_capacity() {
server.verify().await;
}
fn unqueued_context(request_id: &str, reserved: u32) -> AttemptContext {
fn capacity_context(
request_id: &str,
reserved: u32,
queue: ConcurrencyQueuePolicy,
) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new(request_id).unwrap(),
@@ -131,7 +135,9 @@ fn unqueued_context(request_id: &str, reserved: u32) -> AttemptContext {
),
NonZeroU32::new(1).unwrap(),
SystemTime::now() + Duration::from_secs(5),
account_policy().with_openai_guardian_reserved_concurrency(reserved),
account_policy()
.with_openai_guardian_reserved_concurrency(reserved)
.with_queue(queue),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
None,
@@ -158,7 +164,9 @@ fn subagent_operation(subagent: Option<&str>) -> Operation {
}
#[tokio::test]
async fn guardian_requests_can_use_the_reserved_slot_that_normal_requests_cannot() {
async fn guardian_and_normal_requests_use_independent_capacity_and_observations() {
use gateway_core::provider_ports::ProviderConcurrencyPool::{Reserved, Shared};
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_guardian").await;
let account = ProviderAccountId::new("acct_guardian").unwrap();
@@ -171,90 +179,188 @@ async fn guardian_requests_can_use_the_reserved_slot_that_normal_requests_cannot
.insert_header("content-type", "text/event-stream")
.set_body_string(CAPTURE_COMPLETED_SSE),
)
.expect(3)
.expect(4)
.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| {
let signals = |in_flight| AccountRuntimeSignals {
in_flight,
last_started_at: None,
quota_reset_at: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
first_output_latency_ms: None,
};
// 普通上限为 2,审批额度独立取配置值;关闭后新审批请求回到普通池
for (id, normal_count, reserved_count, reserve, subagent, expected) in [
(
"req_large_reserve_normal",
1,
10,
10,
None,
Some((Shared, 2)),
),
("req_normal_full", 2, 0, 1, None, None),
(
"req_guardian_independent",
2,
0,
1,
Some("guardian"),
Some((Reserved, 1)),
),
("req_guardian_full", 0, 1, 1, Some("guardian"), None),
(
"req_normal_independent",
1,
1,
1,
Some("collab_spawn"),
Some((Shared, 2)),
),
(
"req_guardian_disabled",
1,
1,
0,
Some("guardian"),
Some((Shared, 2)),
),
] {
leases
.requests
.signals
.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
.insert(account.clone(), signals(normal_count));
leases
.reserved_signals
.lock()
.unwrap()
.insert(account.clone(), signals(reserved_count));
leases.requests.lock().unwrap().clear();
let result = provider
.clone()
.execute(
planned_request("openai", subagent_operation(None)),
unqueued_context("req_guardian_normal_busy", 1),
planned_request("openai", subagent_operation(subagent)),
capacity_context(id, reserve, ConcurrencyQueuePolicy::default()),
)
.await;
let Some((pool, limit)) = expected else {
assert!(result.is_err(), "{id} should wait for its own capacity");
assert!(leases.requests.lock().unwrap().is_empty());
continue;
};
let mut stream = result.unwrap_or_else(|error| panic!("{id}: {error:?}"));
let capacity = stream
.metadata()
.selection_observation()
.unwrap()
.capacity()
.unwrap();
let count = if pool == Shared {
normal_count
} else {
reserved_count
};
assert_eq!(
(capacity.used_slots(), capacity.total_slots()),
(u64::from(count) + 1, limit)
);
while let Some(event) = stream.next().await {
event.unwrap();
}
drop(stream);
let requests = leases.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].concurrency_pool(), pool);
assert_eq!(u64::from(requests[0].max_concurrent().get()), limit);
}
server.verify().await;
}
#[tokio::test]
async fn a_full_capacity_pool_does_not_block_the_other_pools_queue() {
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(4)
.mount(&server)
.await;
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
Arc::new(MemorySessionAffinity::default()),
server.uri(),
leases.clone(),
);
let queue = ConcurrencyQueuePolicy {
max_waiting: 1,
timeout: Duration::from_secs(2),
};
for guardian_waits in [false, true] {
let blocked_signals = if guardian_waits {
&leases.reserved_signals
} else {
&leases.signals
};
blocked_signals.lock().unwrap().insert(
account.clone(),
AccountRuntimeSignals {
in_flight: if guardian_waits { 1 } else { 2 },
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 mut waiting = Box::pin(provider.clone().execute(
planned_request(
"openai",
subagent_operation(guardian_waits.then_some("guardian")),
),
capacity_context("req_pool_waiter", 1, queue),
));
assert!(waiting.as_mut().now_or_never().is_none());
let mut available = provider
.clone()
.execute(
planned_request(
"openai",
subagent_operation((!guardian_waits).then_some("guardian")),
),
capacity_context("req_other_pool", 1, queue),
)
.await
.is_err()
);
assert_eq!(limits(&leases), [1]);
.expect("the other pool has independent capacity and queue position");
while let Some(event) = available.next().await {
event.unwrap();
}
drop(available);
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();
blocked_signals.lock().unwrap().clear();
let mut resumed = waiting.await.expect("own pool capacity was released");
while let Some(event) = resumed.next().await {
event.unwrap();
}
drop(resumed);
}
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;
}
@@ -773,7 +773,8 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c
let provider = provider_with_base_url(&store, "http://upstream.invalid".to_owned());
let original = json!({"model":"gpt-5.4", "input":[
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["environments.environment_context"], "create_time":1789293131.822}},
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}}
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}},
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}]}
], "tools":[{"type":"web_search"}]});
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", original.as_object().unwrap().clone())
@@ -797,6 +798,7 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c
let expected = expected.unwrap_or("UTC");
assert_eq!(body.pointer("/input/0/content/0/text"), Some(&json!(format!("<environment_context><timezone>{expected}</timezone></environment_context>"))));
assert_eq!(body.pointer("/input/1/content/0/text"), original.pointer("/input/1/content/0/text"));
assert_eq!(body.pointer("/input/2/content/0/text"), body.pointer("/input/0/content/0/text"));
assert_eq!(body.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"), Some(&json!(1789293131.822)));
}
assert_eq!(first_proxy.received_requests().await.unwrap().len(), 2);
@@ -1875,6 +1877,64 @@ async fn provider_should_send_the_request_snapshot_location_to_the_upstream() {
);
}
#[tokio::test]
async fn provider_should_override_unclassified_environment_timezone_over_websocket() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
let base_url = format!("http://{}", listener.local_addr().expect("address"));
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.expect("accept websocket");
let mut socket = accept_codex_test_websocket(socket).await;
let message = socket.next().await.expect("request").expect("valid frame");
let body: Value = serde_json::from_str(message.to_text().expect("text")).expect("JSON");
socket.send(Message::Text(json!({
"type": "response.completed",
"response": {"id": "resp_location", "model": "gpt-5.4", "status": "completed", "output": []}
}).to_string().into())).await.expect("complete response");
body
});
let environment = "<environment_context><cwd>/home/example/项目</cwd><timezone>Asia/Shanghai</timezone></environment_context>";
let payload = ProtocolPayload::json_object(
"openai",
json!({
"model": "gpt-5.4",
"input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": environment}]}],
"tools": [{"type": "web_search"}]
}).as_object().expect("request object").clone(),
)
.expect("payload")
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(true))]));
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
),
context_with_state_owner("req_ws_location", "acct_provider_contract"),
)
.await
.expect("provider stream");
while let Some(event) = stream.next().await {
event.expect("successful websocket response");
}
let body = server.await.expect("server");
assert_eq!(
body.pointer("/input/0/content/0/text"),
Some(&json!(
environment.replace("Asia/Shanghai", "Pacific/Auckland")
))
);
assert_eq!(
body.pointer("/tools/0/user_location/timezone"),
Some(&json!("Pacific/Auckland"))
);
assert!(
body.pointer("/input/0/internal_chat_message_metadata_passthrough")
.is_none()
);
}
async fn capture_turn_state_request(
request_id: &str,
previous_turn_id: Option<&str>,
@@ -783,6 +783,146 @@ async fn child_binding_only_constrains_builtin_scheduling_and_preserves_plugin_c
}
}
#[tokio::test]
async fn connection_local_replay_does_not_claim_or_migrate_a_session() {
use gateway_core::engine::continuation::NativeContinuationScope;
for existing_binding in [true, false] {
for scope_from_state in [false, true] {
for recovery in [
ContinuationAttempt::ReplayOwner,
ContinuationAttempt::ReplayAny,
] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_subagent_a").await;
let affinity = Arc::new(MemorySessionAffinity::default());
let leases = Arc::new(TestLeaseCoordinator::default());
let server = MockServer::start().await;
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
affinity.clone(),
server.uri(),
leases.clone(),
);
if existing_binding {
drop(
provider
.clone()
.execute(
planned_request("openai", turn_request("root", "seed-turn")),
context("req_seed_owner", CancellationToken::new()),
)
.await
.unwrap(),
);
}
create_account(&store, "acct_subagent_b").await;
let renewals = affinity.renewal_ttls();
let aliases = affinity.alias_ttls();
leases.requests.lock().unwrap().clear();
let provider_kind = ProviderKind::new("openai").unwrap();
let account = ProviderAccountId::new("acct_subagent_a").unwrap();
let client = ClientApiKeyId::new("key_openai_contract").unwrap();
let owner = ProviderAccountStateOwner::new(provider_kind.clone(), account.clone());
let pin = NativeContinuationPin::new(
PreviousResponseId::new("client-previous"),
PreviousResponseId::new("upstream-previous"),
client.clone(),
provider_kind,
account.clone(),
)
.with_scope(if scope_from_state {
NativeContinuationScope::Persisted
} else {
NativeContinuationScope::ConnectionLocal
});
// 覆盖 Coordinator 排除旧 owner 后改走 ReplayAny 的入口
let excluded = if recovery == ContinuationAttempt::ReplayAny {
BTreeSet::from([account])
} else {
BTreeSet::new()
};
let attempt = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_invalid_replay").unwrap(),
client,
),
NonZeroU32::new(2).unwrap(),
SystemTime::now() + Duration::from_secs(5),
account_policy(),
AccountAttemptContext::new(excluded, None, Some(owner))
.with_account_scope(contract_account_scope()),
Some(ContinuationBinding::Pinned(pin)),
CancellationToken::new(),
)
.with_continuation_attempt(recovery);
let mut generate = generate_with_session_context("root", Some("root"), None);
if scope_from_state {
// Provider 保存的状态比通用 pin 更精确,不能只检查 pin 的作用域
generate = generate.with_provider_session_state(ProviderSessionState::new(
"openai", json!({
"account_id":"acct_subagent_a", "conversation_id":"conversation-root",
"continuation_scope":"connection_local"
}).as_object().unwrap().clone(),
).unwrap());
}
let error = provider
.clone()
.execute(
planned_request("openai", Operation::Generate(generate)),
attempt,
)
.await
.err()
.expect("connection-local replay requires client recovery");
assert_eq!(
error.kind(),
ProviderErrorKind::ContinuationRecoveryRequired
);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert_eq!(
error.continuation_recovery_disposition(),
Some(ContinuationRecoveryDisposition::ClientReplayRequired)
);
assert_eq!(
affinity.renewal_ttls(),
renewals,
"rejected replay must not write a binding"
);
assert_eq!(affinity.alias_ttls(), aliases);
assert_eq!(affinity.binding_count(), usize::from(existing_binding));
assert!(
leases.requests.lock().unwrap().is_empty(),
"rejected replay must not acquire a lease"
);
if existing_binding {
let child = provider
.clone()
.execute(
planned_request(
"openai",
Operation::Generate(generate_with_session_context(
"root",
Some("child"),
None,
)),
),
context("req_child_after_rejected_replay", CancellationToken::new()),
)
.await
.unwrap();
assert_eq!(
child.metadata().provider_account_id().as_str(),
"acct_subagent_a"
);
}
assert!(server.received_requests().await.unwrap().is_empty());
}
}
}
}
#[tokio::test]
async fn old_native_continuation_cannot_restore_the_pre_migration_account() {
let store = Arc::new(MemoryAccountStore::default());
@@ -263,7 +263,7 @@ async fn selected_adapter_connection(
#[tokio::test]
async fn guardian_reservation_survives_upstream_adapters_and_metadata_precedence() {
// 上限 2、已有 1 个在途请求:预留启用时只有 Guardian 能继续取得租约
// 普通池上限 2 且已满,审批独立池仍有空位;关闭独立池后审批也受普通容量约束
for (subagent, turn_kind, reserved, allowed) in [
(Some("guardian"), None, 1, true),
(None, Some("guardian"), 1, true),
@@ -271,7 +271,8 @@ async fn guardian_reservation_survives_upstream_adapters_and_metadata_precedence
(Some("guardian"), Some("collab_spawn"), 1, false),
(Some("collab_spawn"), None, 1, false),
(None, None, 1, false),
(None, None, 0, true),
(None, None, 0, false),
(Some("guardian"), None, 0, false),
] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
@@ -279,7 +280,7 @@ async fn guardian_reservation_survives_upstream_adapters_and_metadata_precedence
leases.signals.lock().unwrap().insert(
ProviderAccountId::new("acct_provider_contract").unwrap(),
AccountRuntimeSignals {
in_flight: 1,
in_flight: 2,
last_started_at: None,
quota_reset_at: None,
quota_remaining_rank: None,
@@ -355,7 +356,11 @@ async fn guardian_reservation_survives_upstream_adapters_and_metadata_precedence
drop(receiver.await.unwrap());
let requests = leases.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].max_concurrent().get(), 2);
assert_eq!(requests[0].max_concurrent().get(), 1);
assert_eq!(
requests[0].concurrency_pool(),
gateway_core::provider_ports::ProviderConcurrencyPool::Reserved
);
} else {
assert!(leases.requests.lock().unwrap().is_empty());
}
@@ -673,6 +673,7 @@ pub(crate) struct TestLeaseCoordinator {
pub(crate) busy: Mutex<bool>,
pub(crate) busy_accounts: Mutex<BTreeSet<ProviderAccountId>>,
pub(crate) signals: Mutex<BTreeMap<ProviderAccountId, AccountRuntimeSignals>>,
pub(crate) reserved_signals: Mutex<BTreeMap<ProviderAccountId, AccountRuntimeSignals>>,
round_robin_cursor: Mutex<u64>,
}
@@ -682,9 +683,17 @@ impl ProviderLeasePort for TestLeaseCoordinator {
_client_api_key_id: &'a ClientApiKeyId,
_provider_kind: &'a ProviderKind,
accounts: &'a [ProviderAccountId],
pool: gateway_core::provider_ports::ProviderConcurrencyPool,
) -> BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
Box::pin(async move {
let overrides = self.signals.lock().expect("scheduling signals lock");
let overrides = match pool {
gateway_core::provider_ports::ProviderConcurrencyPool::Shared => &self.signals,
gateway_core::provider_ports::ProviderConcurrencyPool::Reserved => {
&self.reserved_signals
}
}
.lock()
.expect("scheduling signals lock");
let signals = accounts
.iter()
.map(|account| {
@@ -94,6 +94,92 @@ fn encoder_should_preserve_location_fields_when_no_override_is_configured() {
assert_eq!(encoded.body().get("model"), Some(&json!("gpt-test")));
}
#[test]
fn encoder_should_align_official_environment_context_without_content_kinds() {
let environment = "<environment_context>\n <environments>\n \
<environment id=\"local\" primary=\"true\"><cwd>/home/example/project</cwd><shell>bash</shell></environment>\n \
</environments>\n <current_date>2020-01-01</current_date>\n \
<timezone>Asia/Shanghai</timezone>\n</environment_context>";
for metadata in [None, Some(json!({"create_time": 1789293131.822}))] {
let mut message = json!({
"type": "message",
"role": "user",
"content": [
{"type": "input_text", "text": "Please explain the current environment"},
{"type": "input_text", "text": environment}
]
});
if let Some(metadata) = metadata {
message["internal_chat_message_metadata_passthrough"] = metadata;
}
let body = json!({"input": [message]})
.as_object()
.expect("request object")
.clone();
let before = Utc::now()
.with_timezone(&New_York)
.format("%Y-%m-%d")
.to_string();
let encoded = encode_generate_request(
&request(body.clone()),
"gpt-test",
Some(&CodexRequestLocation::default()),
)
.expect("encode unclassified official context");
let after = Utc::now()
.with_timezone(&New_York)
.format("%Y-%m-%d")
.to_string();
let encoded = Value::Object(encoded.body().clone());
let aligned = encoded
.pointer("/input/0/content/1/text")
.and_then(Value::as_str)
.expect("aligned environment");
assert!(aligned.contains("<timezone>America/New_York</timezone>"));
assert!([before, after].iter().any(|date| {
aligned
== environment
.replace("2020-01-01", date)
.replace("Asia/Shanghai", "America/New_York")
}));
let original = Value::Object(body.clone());
assert_eq!(
encoded.pointer("/input/0/content/0"),
original.pointer("/input/0/content/0")
);
assert_eq!(
encoded.pointer("/input/0/internal_chat_message_metadata_passthrough"),
original.pointer("/input/0/internal_chat_message_metadata_passthrough")
);
let unchanged = encode_generate_request(&request(body.clone()), "gpt-test", None)
.expect("encode without override");
assert_eq!(unchanged.body().get("input"), body.get("input"));
}
}
#[test]
fn encoder_should_preserve_classified_text_and_non_user_environment_items() {
let environment =
"<environment_context><timezone>Asia/Shanghai</timezone></environment_context>";
let input = json!([
{"role": "user", "content": [{"type": "input_text", "text": environment}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["user.text"]}},
{"role": "user", "content": [{"type": "input_text", "text": environment}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["future.context"]}},
{"role": "user", "content": [{"type": "input_text", "text": environment}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": []}},
{"role": "developer", "content": [{"type": "input_text", "text": environment}]},
{"role": "assistant", "content": [{"type": "output_text", "text": environment}]},
{"type": "function_call_output", "call_id": "synthetic", "output": environment},
{"role": "user", "content": [{"type": "input_text", "text": "<environment_context><timezone>Asia/Shanghai</timezone>"}]}
]);
let encoded = encode_generate_request(
&request(Map::from_iter([("input".to_owned(), input.clone())])),
"gpt-test",
Some(&CodexRequestLocation::default()),
)
.expect("encode without changing non-environment content");
assert_eq!(encoded.body().get("input"), Some(&input));
}
#[test]
fn encoder_should_preserve_openai_wire_fields_without_deriving_accountless_pool_identity() {
let body = Map::from_iter([
@@ -185,6 +271,7 @@ fn encoder_should_patch_model_and_preserve_supported_generate_semantics() {
fn encoder_should_align_structured_location_fields_without_rewriting_chat_text() {
let normal_chat = "<environment_context>\n <current_date>2026-09-13</current_date>\n \
<timezone>Asia/Shanghai</timezone>\n</environment_context>";
let quoted_context = format!("Please explain this example:\n```xml\n{normal_chat}\n```");
let body = json!({
"model": "client-model",
"input": [
@@ -211,7 +298,7 @@ fn encoder_should_align_structured_location_fields_without_rewriting_chat_text()
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": normal_chat}]
"content": [{"type": "input_text", "text": quoted_context}]
}
],
"tools": [
@@ -281,7 +368,7 @@ fn encoder_should_align_structured_location_fields_without_rewriting_chat_text()
);
assert_eq!(
encoded.pointer("/input/2/content/0/text"),
Some(&json!(normal_chat))
Some(&json!(quoted_context))
);
assert_eq!(
encoded.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"),
@@ -191,6 +191,7 @@ impl GrokAccountSessionSelector {
request.client_api_key_id(),
&self.provider_kind,
&account_ids,
gateway_core::provider_ports::ProviderConcurrencyPool::Shared,
)
.await
.map_err(|source| {
@@ -715,6 +715,7 @@ impl ProviderLeasePort for TestLeases {
_: &'a gateway_core::policy::ClientApiKeyId,
_: &'a gateway_core::routing::ProviderKind,
accounts: &'a [ProviderAccountId],
_pool: gateway_core::provider_ports::ProviderConcurrencyPool,
) -> BoxFuture<
'a,
Result<gateway_core::provider_ports::ProviderSchedulingState, ProviderStoreError>,
@@ -1255,6 +1256,7 @@ mod errors {
_: &'a ClientApiKeyId,
_: &'a ProviderKind,
_: &'a [ProviderAccountId],
_pool: gateway_core::provider_ports::ProviderConcurrencyPool,
) -> BoxFuture<
'a,
Result<gateway_core::provider_ports::ProviderSchedulingState, ProviderStoreError>,
@@ -265,6 +265,7 @@ impl ProviderLeasePort for TestRefreshLeases {
_: &'a gateway_core::policy::ClientApiKeyId,
_: &'a gateway_core::routing::ProviderKind,
_: &'a [gateway_core::account::ProviderAccountId],
_pool: gateway_core::provider_ports::ProviderConcurrencyPool,
) -> futures::future::BoxFuture<
'a,
Result<gateway_core::provider_ports::ProviderSchedulingState, ProviderStoreError>,
@@ -59,6 +59,7 @@ impl ProviderLeasePort for SchedulingCoordinator {
_: &'a ClientApiKeyId,
_: &'a gateway_core::routing::ProviderKind,
_: &'a [ProviderAccountId],
_pool: gateway_core::provider_ports::ProviderConcurrencyPool,
) -> futures::future::BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
Box::pin(async move {
Ok(ProviderSchedulingState::new(
@@ -1839,6 +1839,7 @@ impl ProviderLeasePort for DiagnosticLeasePort {
_: &'a ClientApiKeyId,
_: &'a ProviderKind,
account_ids: &'a [gateway_core::account::ProviderAccountId],
_pool: gateway_core::provider_ports::ProviderConcurrencyPool,
) -> futures::future::BoxFuture<'a, Result<ProviderSchedulingState, ProviderStoreError>> {
Box::pin(async move {
Ok(ProviderSchedulingState::new(
+32 -1
View File
@@ -7,7 +7,7 @@
| 任务 | 入口 |
| --- | --- |
| 安装与启动 | [手动安装](#手动安装) · [启动](#启动) · [公网访问](#公网访问) |
| 接入客户端 | [客户端配置](#客户端配置) · [登录与生图排查](#登录与生图排查) |
| 接入客户端 | [客户端配置](#客户端配置) · [时区与客户端环境信息](#时区与客户端环境信息) · [登录与生图排查](#登录与生图排查) |
| 日常运维 | [运行设置](#启动后的运行设置) · [小内存优化](#小内存优化) · [持久化与日志](#持久化与备份) · [请求排查](#请求错误排查) · [密码轮换](#密码语义) |
| 更新与恢复 | [镜像升级](#镜像升级与源码构建) · [在线更新](#管理端在线更新) · [备份与恢复](#备份与恢复) · [优雅关停](#优雅关停) |
| 运行源码 | [开发与源码联调](../docs/development.md) |
@@ -362,6 +362,37 @@ OpenAI OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不
`image_generation` 和 `goals` 仍有效。不要为了接入代理,顺带扩大命令沙箱的联网或文件权限
### 时区与客户端环境信息
要覆盖模型请求中的位置和时区,在管理端启用全局自定义位置,或为账号关联的代理设置位置;
代理位置优先。`host.timezone` 控制部署日历和管理端时间展示,不代替请求位置设置。
覆盖字段与环境消息识别条件见 [代理位置合同](../docs/api.md#独立代理管理--managed-proxies)
Codex 回答仍显示本机时区时,先核对请求是否使用了预期账号和代理,再区分环境正文与工具结果:
- 使用新会话询问环境上下文中的时区,避免已有工具输出或历史回答影响判断
- 检查实际环境消息是否满足完整上下文形状;显式普通文本分类或非法 XML 不会被覆盖
- 若模型调用 `date` 等工具查询本机,工具结果仍反映真实系统时区,覆盖不会修改客户端操作系统
网关的请求头画像和位置覆盖不提供完整匿名化。正文与工作区 metadata 的保留范围见
[正文兼容](../docs/api.md#正文兼容),xAI 请求不应用 OpenAI 的请求位置覆盖
Codex 客户端还有独立的出站请求。analytics 使用 `chatgpt_base_url`,可以包含真实系统类型、
系统版本和架构;模型 Provider 的 `base_url` 不改变该地址。已核对的官方 release 配置默认启用
Statsig 指标,目标为 `https://ab.chatgpt.com/otlp/v1/metrics`,不由网关上游画像控制
需要关闭这两类遥测时,将以下字段合并到客户端 `config.toml`,避免重复创建已有表,完全退出并重启 Codex:
```toml
[analytics]
enabled = false
[otel]
metrics_exporter = "none"
```
这两个开关不清理模型请求中的工作目录、Git metadata、用户内容或工具结果。
### 登录与生图排查
- 仍提示登录:确认实际读取的用户配置目录、选中的 Provider 和客户端版本,再完全退出重启。
+25 -10
View File
@@ -295,6 +295,10 @@ Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品
请求头过滤不提供客户端匿名化;系统提示词、工具定义、工具结果、工作目录及其他业务 metadata
保持原有语义,可能包含客户端环境信息。
环境正文中的工作区路径、shell 和文件权限信息不会因位置覆盖而隐藏。
turn metadata 的 `workspaces` 也会保留仓库绝对路径、Git 远端地址、提交及工作区变更状态;
安装身份和账号绑定字段的处理不提供这些信息的脱敏
`client_metadata.parent_response_id` 是 Guardian 的账号内响应引用,只有归属可信且仍为同一账号时保留;
切号或归属未知时移除。`x-codex-guardian`、`guardian_credits_requested` 和序列化
`x-codex-turn-metadata` 内普通扩展的同名 `parent_response_id` 保持原样
@@ -644,7 +648,7 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前
- `sortBy`: `email`、`status`、`planType`、`usage`、`lastUsedAt`、`expiresAt`
- `sortDirection`: `asc`、`desc`
账号视图的 `capacity` 返回查询时的网关并发容量:`usedSlots` 是正在执行的请求占用数,不含排队请求,
账号视图的 `capacity` 返回查询时的普通并发容量:`usedSlots` 不含独立审批池和排队请求,
读取运行态失败时为 `null`;`totalSlots` 是应用账号独立配置或全局默认值后的上限,`null` 表示不限。
该上限不代表上游实际允许的并发数
@@ -755,11 +759,21 @@ API 的 `autoLocation` 默认为 `false`;开启时使用已检测位置,测
关联账号的 OpenAI/Codex Responses 请求(HTTP/SSE、WebSocket)优先使用代理位置,否则使用全局
运行设置中已开启的 `requestLocation`;两者均未开启时保留客户端原有位置和时区。全局覆盖按请求冻结,
新请求使用保存后的设置,无需重启;代理覆盖在每次执行时读取,
换号或换出口按该次选定账号解析。位置只影响带来源标记的环境上下文日期/时区和 Web Search 的结构化位置,
不改变用户普通文本、epoch 时间戳、真实出口 IP、服务或管理端时区、数据驻留约束及 xAI 请求
换号或换出口按该次选定账号解析。位置只影响环境上下文日期/时区和 Web Search 的结构化位置,
不改变 epoch 时间戳、真实出口 IP、服务或管理端时区、数据驻留约束及 xAI 请求
环境消息要求 `role: "user"`,文本块为 `type: "input_text"`,完整文本去除首尾空白后由
`<environment_context>` 与 `</environment_context>` 包围且为合法 XML;只替换根节点直属的
`current_date` 和 `timezone`。有 `internal_chat_message_metadata_passthrough.content_item_kinds` 数组时,
仅处理对应分类为 `environments.environment_context` 的文本块;没有分类时,按完整环境上下文识别。
显式标为 `user.text` 或其他分类的内容、普通聊天中引用的示例、工具结果及无法解析的上下文保持原样。
客户端实际本机时区不受影响,工具读取本机时区后的输出仍可包含真实值;排查见
[时区与客户端环境信息](../deploy/README.md#时区与客户端环境信息)
测试经代理并发访问 IPv4 专用端点 `https://api.ipify.org?format=json` 与 IPv6 专用端点 `https://api6.ipify.org?format=json`,
分别验证并记录双栈出口(IPv4 与 IPv6 地址),在任一地址族可用时即判定连接成功。超时 15 秒,每进程最多同时测试 4 条。
解析位置时还会向 `https://ipwho.is/<出口 IP>` 查询地理位置,出口 IP 会发送给该第三方服务;
自定义位置和时区不改变这些探测请求
探测器复用 OpenAI 的证书信任配置:优先读取非空的 `CODEX_CA_CERTIFICATE`,
其次读取 `SSL_CERT_FILE`,并保留系统根证书;证书配置错误不会回退为不验证证书。
出口测试结果仅供诊断,不限制代理的选择和绑定;未测试或测试失败的代理仍可使用。
@@ -1177,7 +1191,7 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造
`providerCounts` 和 `clientKeyCount`。查询分组成员使用账号列表的 `groupId` 筛选,
不提供独立的分组成员路由;账号的 Provider 不代表整个分组的 Provider。
`capacity.totalSlots` 为 `number | null`:`null` 表示可用成员中存在继承无限并发的账号,`0` 表示没有可用槽位。
`capacity.usedSlots` 继续返回实际在途数;Redis 不可用时为 `null`
分组容量只统计普通池,`capacity.usedSlots` 返回其实际在途数;Redis 不可用时为 `null`
分组费用按请求执行时实际服务账号的分组快照归属,不按 Client Key 绑定的分组分摊。
账号属于多个组时,各组均包含该请求费用;之后调整账号分组不重写历史归属
@@ -1361,12 +1375,13 @@ OpenAI 严格亲和下后代线程的会话账号等待不随普通账号排队
切换账号或内部重试不重新计时;若插件设置了请求总时限,等待也计入该时限。排队超时不用于中断已开始的上游生成。
设置更新请求须包含这三个字段,新请求使用更新后的快照
`openaiGuardianReservedConcurrency`(默认 0,取值 0~4,294,967,295)为 Codex Guardian 自动审批保留账号并发,保存后对新请求生效。
`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 之后、全部普通等待者之前,不受单账号排队上限约束,
仍受总等待容量与等待时限约束。不限并发的账号和关闭排队时的其余行为不变。
设置更新请求须包含该字段
普通请求保留账号原上限 L,Guardian 单独计数并最多运行 R 个,两类请求互不占用名额,也不借用对方空位。
例如 L 为 10、R 为 3 时,可以同时运行 10 个普通请求和 3 个审批请求;普通并发不限时,审批仍受 R 限制。
设为 0 时关闭独立额度,Guardian 使用普通并发。开启账号排队后,审批请求单独按 FIFO 等待,
不受普通队列位置或单账号排队上限约束,仍受自身队列总等待容量与等待时限约束。
两类请求仍遵守账号最小请求间隔、可用性和 Client Key 限额。设置更新请求须包含该字段
`openaiAccountAffinity` 控制 OpenAI 账号亲和,默认 `strict`(严格),已有保存的模式保持不变:
@@ -1710,7 +1725,7 @@ errorCode, errorMessage, startedAt, completedAt, expiresAt, createdAt, updatedAt
### Dashboard 容量与账号用量
Dashboard 的 `capacityInfo.maxConcurrentPerAccount` 为默认账号并发上限,`0` 表示不限制。
Dashboard 的 `capacityInfo` 统计普通容量,不含独立审批池;`maxConcurrentPerAccount` 为默认账号并发上限,`0` 表示不限制。
`capacityInfo.totalSlots` 为 `number | null`;可用账号池含无限并发账号时为 `null`,此时 `availableSlots` 也为 `null`。
`usedSlots` 仍表示实际在途数,Redis 不可用时为 `null`;没有可用账号时 `totalSlots` 为 `0`
+15 -11
View File
@@ -614,7 +614,9 @@ Continuation 仍受原请求的 Client Key、账号范围、Provider 和发送/
- OpenAI 在交付前收到可安全重放的明确额度拒绝时,先隔离账号,再投影 `ClientReplayRequired`;
丢弃未交付的原错误帧,由客户端提交完整历史开启新链,不把原增量输入交给其他账号。
真实错误分类、状态码、发送状态和上游诊断保持不变,客户端合同见 [Responses API](api.md#3-openai-数据面与模型目录)
- OpenAI 按 native → replay owner → replay any 推进,并保留官方 `previous_response_id` 语义
- OpenAI 按 native → replay owner → replay any 推进,并保留官方 `previous_response_id` 语义;
已知为 connection-local 的原生续接进入重放阶段时,在选号前返回 `ClientReplayRequired`,不认领或迁移会话绑定。
正常 native 续接仍由连接池核对原连接及 response ID 是否可用
- xAI 使用客户端提交的完整历史作为重放输入
- scope 外账号、跨 Key 复用或不明确发送结果均 fail closed
@@ -655,21 +657,23 @@ Key 的 RPM 在成功准入时才计数,金额限制在入队前及成功准
切换等待层、账号或 Provider 不重置等待时限;没有发生排队时不启动该计时,也不据此中断已开始的上游生成。
队列满/超时属于本地容量拒绝,不作为上游限流反馈或 Provider 故障熔断证据
#### Guardian 审批优先
#### Guardian 独立并发
Guardian 预留值由运行设置持久化并随调度快照冻结,每个请求沿用开始时的设置。
Core 传递该值并拥有通用的并发上限与等待顺序,OpenAI Provider 负责识别请求类型并决定预留与优先级。
Guardian 独立额度由运行设置持久化并随调度快照冻结,每个请求沿用开始时的设置。
Core 拥有容量池、上限与等待合同,OpenAI Provider 负责识别请求类型并选择普通池或审批池。
- OpenAI Provider 在选号前通过统一协议解析识别 `guardian`;原生发送与插件上游适配器使用相同分类。
请求头形成的协议上下文与正文 metadata 沿用编码器的既有优先顺序;其他协议不得仅凭同名字段获得该优先级。
请求头形成的协议上下文与正文 metadata 沿用编码器的既有优先顺序;其他协议不得仅凭同名字段使用审批池。
适配器不因此继承原生会话亲和、续接或 WebSocket 约束,选号后的 attempt 中间件也不追溯改变已取得的租约。
- `openaiGuardianReservedConcurrency = R > 0` 时,有限账号上限 L 对普通请求为 `max(L - R, 1)`,
Guardian 可用满 L,资格判断、策略投影、容量观测和原子租约共用同一请求级上限,数值边界见 [运行设置 API](api.md#8-运行设置)
- 开启账号排队时,Guardian 在普通等待者之前、已有 Guardian 之后;同级 FIFO,不抢占已运行请求。
Guardian 可越过单账号等待数量上限,但仍受总等待容量、共享等待预算与截止时刻约束;取消及超时必须释放等待位置。
- 配置为 0 时不启用预留或优先级;账号排队关闭时不引入隐式队列。Key 层准入仍按原有限额与排队规则执行。
- `openaiGuardianReservedConcurrency = R > 0` 时,普通池使用账号原上限,审批池使用独立上限 R。
选号信号、负载评分、策略投影、请求容量观测和原子租约使用同一池的计数与上限,数值边界见 [运行设置 API](api.md#8-运行设置)
- Store 按账号和容量池隔离租约,复用续期、取消与释放生命周期,不复制总计数。
最小请求间隔仍按账号共享;管理端普通容量和自适应并发下调的观测只计普通池
- 两个池分别持有等待队列,任一池的队首不会阻塞另一池。审批等待者保持 FIFO,
可越过单账号等待数量上限,但仍受自身队列的总等待容量、共享等待预算与截止时刻约束;取消及超时必须释放等待位置
- 配置为 0 时审批请求使用普通池;账号排队关闭时不引入隐式队列。Key 层准入仍按原有限额与排队规则执行
等待者重新选号及取得租约前复核当前队首,异步读取期间被插队的普通请求继续等待,不凭已有 ticket 跳过优先顺序。
等待者重新选号及取得租约前复核所属池的当前队首,不凭已有 ticket 跳过等待顺序
### Client Key 限额与结算
@@ -4,13 +4,14 @@ import type { UsageListRecord } from '@/api'
import { Archive, ArrowDown, ArrowUp } from '@lucide/vue'
import { computed } from 'vue'
import { usageTokenDetails } from '@/components/usage/shared/presenter'
import UsageDetailPopover from '@/components/usage/UsageDetailPopover.vue'
const props = defineProps<{
record: Pick<UsageListRecord, 'tokenDetails'>
}>()
const tokenDetails = computed(() => props.record.tokenDetails)
const tokenDetails = computed(() => usageTokenDetails(props.record.tokenDetails))
const tokenItems = computed(() => [
{ label: '输入 Token', value: tokenDetails.value.inputTokensDisplay },
{ label: '输出 Token', value: tokenDetails.value.outputTokensDisplay },
@@ -1,5 +1,5 @@
import type { UsageListRecord, UsageRecordDetail } from '@/api'
import { formatCompactNumber, formatDuration } from '@/utils/format'
import type { UsageListRecord, UsageRecordDetail, UsageTokenDetails } from '@/api'
import { formatCompactNumber, formatDuration, formatInteger } from '@/utils/format'
// 列表与详情使用独立读模型,展示函数只依赖两者的公共字段。
type UsageCommonRecord = UsageListRecord | UsageRecordDetail
@@ -9,6 +9,23 @@ export type UsagePerformanceRecord = UsageLatencyRecord & {
tokenDetails: Pick<UsageListRecord['tokenDetails'], 'outputTokens'> | null
}
export function usageFreshInputTokens(input: number, cached: number, written: number) {
// 输入总量包含缓存读写,普通输入与缓存明细分开展示
return Math.max(0, input - cached - written)
}
export function usageTokenDetails(details: UsageTokenDetails): UsageTokenDetails {
const inputTokens = details.inputTokens === null
? null
: usageFreshInputTokens(details.inputTokens, details.cachedTokens ?? 0, details.cacheWriteTokens ?? 0)
return {
...details,
inputTokens,
inputTokensDisplay: inputTokens === null ? details.inputTokensDisplay : formatInteger(inputTokens),
}
}
export function usageTransportType(transport?: string | null) {
if (transport === 'websocket')
return 'WS'
@@ -1,5 +1,6 @@
import type { KeyUsageMetrics } from '@/api/modules/key-usage'
import { ArrowDown, ArrowUp, Brain, Database, Sparkles } from '@lucide/vue'
import { usageFreshInputTokens } from '@/components/usage/shared/presenter'
// 卡片、曲线和图例共用语义与主题色,避免同一指标出现不同颜色。
export const keyUsageTokenMetrics = [
@@ -12,11 +13,6 @@ export const keyUsageTokenMetrics = [
export function keyUsageTokenValue(metrics: KeyUsageMetrics, key: typeof keyUsageTokenMetrics[number]['key']) {
return key === 'inputTokens'
? freshInput(metrics.inputTokens, metrics.cachedTokens, metrics.cacheWriteTokens)
? usageFreshInputTokens(metrics.inputTokens, metrics.cachedTokens, metrics.cacheWriteTokens)
: metrics[key]
}
function freshInput(input: number, cached: number, written: number) {
// 输入总量已经包含缓存读写,不把缓存再次计入消耗。
return Math.max(0, input - cached - written)
}
@@ -58,21 +58,21 @@ const openaiGuardianReservedConcurrency = defineModel<string>('openaiGuardianRes
<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 表示关闭">
<BaseFormItem label="自动审批独立并发" description="每个账号额外的审批名额,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">
<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>
<p>仅对 OpenAI 账号生效,普通并发为 10、审批名额为 3 时,两类请求分别最多运行 10 个和 3 个,互不占用名额</p>
<p>开启独立额度后,审批请求单独排队,可超过账号队列容量,仍受总等待容量、排队超时、请求间隔与密钥限额约束</p>
</div>
</BasePopover>
</template>
<BaseInput v-model="openaiGuardianReservedConcurrency" aria-label="自动审批预留并发" type="number" min="0" max="4294967295" step="1" />
<BaseInput v-model="openaiGuardianReservedConcurrency" aria-label="自动审批独立并发" type="number" min="0" max="4294967295" step="1" />
</BaseFormItem>
</BaseForm>
</BaseCard>
@@ -227,7 +227,7 @@ export function useSettingsForm() {
return
}
if (!Number.isInteger(openaiGuardianReservedConcurrency) || openaiGuardianReservedConcurrency < 0 || openaiGuardianReservedConcurrency > 4294967295) {
toast.warning('自动审批预留并发应为 0~4294967295 的整数,0 表示关闭')
toast.warning('自动审批独立并发应为 0~4294967295 的整数,0 表示共用普通并发')
return
}
if (responsesMaxDecompressedBodyMiB === null || !Number.isInteger(responsesMaxDecompressedBodyMiB) || responsesMaxDecompressedBodyMiB < 1
@@ -13,6 +13,7 @@ import {
usageLatencyDetails,
usageModelDisplay,
usageReasoningEffort,
usageTokenDetails,
usageTransportType,
usageUserAgent,
} from '@/components/usage/shared/presenter'
@@ -37,7 +38,7 @@ const responseText = computed(() => props.record ? visibleResponseText(props.rec
const modelDisplay = computed(() => props.record
? usageModelDisplay(props.record)
: { primary: '—', secondary: '' })
const tokenDetails = computed(() => props.record ? props.record.tokenDetails : null)
const tokenDetails = computed(() => props.record ? usageTokenDetails(props.record.tokenDetails) : null)
const billing = computed(() => props.record ? props.record.billing : null)
const latencyDetails = computed(() => props.record ? usageLatencyDetails(props.record) : null)
+8 -13
View File
@@ -1,19 +1,14 @@
# v3.21.2
# v3.21.3
## 修复与改进
- 修复 OpenAI 严格亲和下,子线程或独立记忆整理任务因会话尚未绑定账号而等待超时的问题:任何有可靠会话身份的请求均可按调度策略原子首绑,并发首请求最终共用同一绑定;已有绑定的子线程仍跟随当前账号,迁移权限保持不变
- xAI 模型目录对齐当前 CLI proxy 的模型、推理档位及上下文窗口字段;推理档位按请求保留,不再根据旧模型名称降档或删除,缺失能力与默认值保持未知
- 修复 xAI 托管搜索工具与客户端函数同名时的调用及跨轮重放映射,支持 `web_search.filters.excluded_domains`,校验允许与排除域名列表不能同时非空
- xAI 额度判断采用当前账单字段,保留预付余额的原始符号,不再凭过期快照或旧字段恢复账号;上游报告零费用时视为费用未提供,只有具备完整计价依据时才使用本地估算
- 修复备份取消或失败时的导出进程、分片上传与暂存文件清理,恢复已完成上传的任务后及时清理本地归档,并记录清理失败
- 修复服务关闭时请求排空与后台写入的先后顺序,以及 WebSocket 关闭握手可能被未完成写入阻塞的问题
- 修复 Key 预算重置后,应用与数据库时钟差异可能使未开启窗口被误判为活跃窗口的问题
- 账号状态读取改为有界分批并复用查询结果,避免账号池规模直接形成 Redis 并发峰值;完善 OpenAI 重置卡消费的账号锁回收,保持取消后的同账号串行约束
- 修复 Codex 使用自定义 Provider 或关闭内容分类时,请求位置覆盖可能漏掉环境消息中的日期与时区的问题;缺少分类时仍识别完整、合法的环境上下文,HTTP 与 WebSocket 均适用(#366)
- Codex Guardian 自动审批改为每账号独立并发与独立等待队列,普通请求保留原并发上限,两类请求互不占用名额;审批仍遵守账号请求间隔、等待时限与 Key 限额
- 修复 OpenAI 仅能在原连接继续的原生续接进入重放阶段时,失败请求可能先选择账号或改变会话绑定的问题;现在在选号前返回客户端重放提示,正常原生续接仍按原连接校验(#364)
- 修正用量记录列表与详情的输入 Token 展示,扣除缓存读取与写入量,与 Key 用量页面保持相同口径;原始用量与计费口径保持不变
## 升级事项
- 从 v3.21.1 升级无需新增数据库迁移或调整亲和配置;严格亲和不再要求根线程必须先发请求才能建立会话绑定
- xAI 请求保留路由选定的完整模型名,不再自动解释 `grok`、`grok-latest` 等旧别名或剥离模型前缀;依赖这些名称的配置应使用显式模型映射
- xAI 搜索域名白名单应放在 `web_search.filters.allowed_domains`,旧顶层 `allowed_domains` 返回请求错误;推理档位未知或类型错误时也会明确返回字段错误
- 内建 xAI 适配器对 `generate=false` 预热返回 `400 unsupported_prewarm`,避免误发生成请求;客户端应跳过该预热并继续正式请求
- 从 v3.21.2 升级无需新增数据库迁移
- 已保存的自动审批并发配置继续生效,但大于 0 时改为额外的独立额度:普通并发为 10、审批额度为 3 时,可以同时运行 10 个普通请求与 3 个审批请求;设为 0 时仍共用普通并发
- 请求位置覆盖只修改请求中的日期、时区与位置字段,不修改客户端操作系统;模型通过 `date` 等工具读取的本机时区仍可能是真实值
+1 -1
View File
@@ -1 +1 @@
version: "3.21.2"
version: "3.21.3"