mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 00:07:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb397ae53e | ||
|
|
63802f9b1a | ||
|
|
de599ed768 | ||
|
|
3315ddbff6 | ||
|
|
6c583e341a |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 @@
|
||||
version: "3.21.2"
|
||||
version: "3.21.3"
|
||||
|
||||
Reference in New Issue
Block a user