Compare commits

..
8 Commits
Author SHA1 Message Date
ZyyandCodex 28ad8fb1c4 chore: bump version to 3.46.0
Co-authored-by: Codex <noreply@openai.com>
2026-11-12 21:12:25 +08:00
ZyyandZyy 4888681a04 feat(scheduling): weight quota resets by window in smart scheduling (#694)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
2026-11-12 18:56:30 +08:00
9739bb08be fix(egress): honor deployment proxies for unbound outbound requests (#693)
* fix(egress): honor deployment proxies for unbound outbound requests

Prefer the proxy selected for the request. Otherwise apply the deployment proxy and bypass rules consistently to HTTP and WebSocket connections, including background downloads. Keep explicit direct probes separate.

Co-authored-by: Codex <noreply@openai.com>

* fix(openai): preserve WebSocket dial errors

Recover the OS error or I/O kind from Hyper without retaining the endpoint. Keep refused connections distinguishable for both direct and proxied WebSocket dials.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-12 17:29:35 +08:00
fac37ec563 fix(update): preserve permissions when moving read-only directories (#688)
* fix(update): unlock official plugin dirs before rename on macOS

macOS rejects renaming a 0555 official plugin directory during backup
and rollback, while Linux only needs the parent writable.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(update): preserve directory permissions during macOS moves

Fall back to the existing permission-preserving copy path when macOS
rejects a directory rename. Keep chmod limited to source cleanup so
backups and restored releases retain their original directory modes.

Cover read-only staged assets, rollback, failed replacement, and
cancelled or rejected rollback with permission assertions.

Co-authored-by: Codex <noreply@openai.com>

* fix(update): handle read-only directory moves across Unix platforms

Linux also requires source directory write permission when rename moves
it between parents. Apply the same copy fallback to Unix while retaining
the cross-platform permission regression test.

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: CzsGit <czsde@outlook.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-12 13:20:16 +08:00
9f0d712671 fix(openai): honor advertised WebSocket message limits (#691)
Use each upstream handshake's message-size limit to select HTTP before
uploading oversized response.create frames. Remove fixed thresholds and
thread size hysteresis while preserving continuation replay and cleanup.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-11-12 13:19:56 +08:00
ZyyandCodex 75be1287ef fix(openai): omit output token limits from Responses requests
Co-authored-by: Codex <noreply@openai.com>
2026-11-12 10:24:27 +08:00
ZyyandCodex dad91fe9f7 fix(ui): preserve semantic and neutral colors across theme presets
Co-authored-by: Codex <noreply@openai.com>
2026-11-11 20:48:41 +08:00
github-actions[bot] b22b0cfa41 chore(release): update release channels to 3.45.2 2026-11-11 09:28:26 +00:00
124 changed files with 1998 additions and 972 deletions
+14
View File
@@ -1807,6 +1807,17 @@ dependencies = [
"uuid",
]
[[package]]
name = "gateway-egress"
version = "0.0.0"
dependencies = [
"gateway-core",
"http",
"hyper-util",
"reqwest",
"url",
]
[[package]]
name = "gateway-host"
version = "0.0.0"
@@ -1822,6 +1833,7 @@ dependencies = [
"futures",
"gateway-admin",
"gateway-core",
"gateway-egress",
"gateway-protocol",
"hex",
"http",
@@ -3353,6 +3365,7 @@ dependencies = [
"futures",
"gateway-admin",
"gateway-core",
"gateway-egress",
"gateway-host",
"gateway-protocol",
"getrandom 0.4.3",
@@ -3410,6 +3423,7 @@ dependencies = [
"futures",
"gateway-admin",
"gateway-core",
"gateway-egress",
"gateway-protocol",
"getrandom 0.4.3",
"jsonwebtoken",
+1
View File
@@ -5,6 +5,7 @@ members = [
"crates/gateway-admin",
"crates/gateway-api",
"crates/gateway-core",
"crates/gateway-egress",
"crates/gateway-host",
"crates/gateway-plugin/runtime",
"crates/gateway-plugin/sdk",
@@ -16,6 +16,7 @@ pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
"crates/gateway-admin",
"crates/gateway-api",
"crates/gateway-core",
"crates/gateway-egress",
"crates/gateway-host",
"crates/gateway-plugin/runtime",
"crates/gateway-plugin/sdk",
@@ -447,6 +448,7 @@ const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
("gateway-admin", "crates/gateway-admin"),
("gateway-api", "crates/gateway-api"),
("gateway-core", "crates/gateway-core"),
("gateway-egress", "crates/gateway-egress"),
("gateway-host", "crates/gateway-host"),
("gateway-plugin-runtime", "crates/gateway-plugin/runtime"),
("gateway-plugin-sdk", "crates/gateway-plugin/sdk"),
@@ -510,8 +512,10 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
("gateway-api", "gateway-core"),
("gateway-api", "gateway-protocol"),
("gateway-core", "gateway-protocol"),
("gateway-egress", "gateway-core"),
("gateway-host", "gateway-admin"),
("gateway-host", "gateway-core"),
("gateway-host", "gateway-egress"),
("gateway-host", "gateway-protocol"),
("gateway-plugin-runtime", "gateway-host"),
("gateway-plugin-runtime", "gateway-core"),
@@ -522,9 +526,11 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
("gateway-store", "gateway-core"),
("provider-openai", "gateway-admin"),
("provider-openai", "gateway-core"),
("provider-openai", "gateway-egress"),
("provider-openai", "gateway-protocol"),
("provider-xai", "gateway-admin"),
("provider-xai", "gateway-core"),
("provider-xai", "gateway-egress"),
("provider-xai", "gateway-protocol"),
];
@@ -78,7 +78,12 @@ async fn proxy_probe_should_use_provider_custom_ca_for_https_proxies() {
});
let probe = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_client_builder(provider_openai::build_reqwest_client_with_custom_ca);
let result = probe.test(Some(&proxy), false).await;
let result = probe
.test(
gateway_admin::ports::proxy::ProbeRoute::Proxy(&proxy),
false,
)
.await;
assert!(result.success, "{}", result.message);
assert_eq!(result.exit_ip.unwrap().to_string(), "203.0.113.8");
tokio::time::timeout(Duration::from_secs(5), server)
@@ -146,7 +146,7 @@ pub struct AccountRecord {
}
impl AccountRecord {
/// 配置保留绑定,执行请求时禁用代理按直连处理
/// 配置保留绑定,执行请求时禁用代理使用部署默认出口
#[must_use]
pub fn effective_outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
self.outbound_proxy
@@ -69,8 +69,14 @@ pub struct ProxyImportReservation {
pub guard: Box<dyn ProxyImportGuard>,
}
#[derive(Clone, Copy)]
pub enum ProbeRoute<'a> {
Direct,
Deployment,
Proxy(&'a OutboundProxy),
}
#[async_trait]
pub trait ProxyProbe: Send + Sync {
/// 未指定代理时探测服务器直连出口,不继承进程代理环境变量
async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult;
async fn test(&self, proxy: ProbeRoute<'_>, detect_location: bool) -> ProxyTestResult;
}
@@ -306,7 +306,9 @@ impl PluginsService {
.await
.map_err(|error| map_store_error(error, "proxy"))?;
if !record.enabled {
return Err(AdminError::invalid("下载代理已禁用,请启用或改为直连"));
return Err(AdminError::invalid(
"下载代理已禁用,请启用或改用部署默认出口",
));
}
Ok(Some(PluginDistributionEgress::new(
record.id,
@@ -23,7 +23,7 @@ use crate::{
},
ports::{
provider::ProviderAdminRegistry,
proxy::{PluginProxyAccess, ProxyProbe, ProxyStore},
proxy::{PluginProxyAccess, ProbeRoute, ProxyProbe, ProxyStore},
store::AdminStoreErrorKind,
},
};
@@ -47,7 +47,7 @@ impl PluginProxyAccess for DefaultPluginProxyAccess {
.get(id)
.await
.map_err(|error| map_store_error(error, "proxy"))?;
// 显式选择代理时必须有可用配置,不能沿用账号停用代理后直连的语义
// 显式选择代理时必须有可用配置,不能沿用账号停用代理后使用部署默认出口的语义
if !record.enabled {
return Err(AdminError::invalid("代理已禁用,请启用后再选择"));
}
@@ -114,10 +114,10 @@ pub trait ProxiesService: Send + Sync {
detect_location: bool,
context: &MutationContext,
) -> Result<ProxyRecord, AdminError>;
/// 探测未保存的连接地址或服务器直连出口,不写入配置或修改账号绑定
/// 探测未保存的连接地址、部署默认出口或服务器直连出口,不写入配置或修改账号绑定
async fn probe(
&self,
proxy: Option<&OutboundProxy>,
proxy: ProbeRoute<'_>,
detect_location: bool,
) -> Result<ProxyTestResult, AdminError>;
}
@@ -323,7 +323,7 @@ impl ProxiesService for DefaultProxiesService {
.transpose()
.map_err(|_| AdminError::invalid("代理位置不合法"))?;
command.test = if command.auto_location {
Some(self.probe(Some(&command.proxy), true).await?)
Some(self.probe(ProbeRoute::Proxy(&command.proxy), true).await?)
} else {
None
};
@@ -362,7 +362,7 @@ impl ProxiesService for DefaultProxiesService {
if command.auto_location.unwrap_or(current.auto_location)
&& (!current.auto_location || proxy != &current.proxy)
{
command.test = Some(self.probe(Some(proxy), true).await?);
command.test = Some(self.probe(ProbeRoute::Proxy(proxy), true).await?);
}
}
let result = self
@@ -391,7 +391,7 @@ impl ProxiesService for DefaultProxiesService {
async fn probe(
&self,
proxy: Option<&OutboundProxy>,
proxy: ProbeRoute<'_>,
detect_location: bool,
) -> Result<ProxyTestResult, AdminError> {
let _permit = self
@@ -423,7 +423,10 @@ impl ProxiesService for DefaultProxiesService {
// 手动解析只为本次测试请求位置,不改变自动跟随的持久配置
let result = self
.probe
.test(Some(&record.proxy), record.auto_location || detect_location)
.test(
ProbeRoute::Proxy(&record.proxy),
record.auto_location || detect_location,
)
.await;
let mutation = self
.store
@@ -4,7 +4,7 @@ use async_trait::async_trait;
use gateway_admin::{
model::{MutationContext, Revision, proxies::*},
ports::{
proxy::{ProxyProbe, ProxyStore},
proxy::{ProbeRoute, ProxyProbe, ProxyStore},
store::AdminStoreResult,
},
};
@@ -194,7 +194,7 @@ async fn plugin_proxy_resolution_requires_enabled_configuration_without_account_
#[async_trait]
impl ProxyProbe for TestProxies {
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
async fn test(&self, _: ProbeRoute<'_>, _: bool) -> ProxyTestResult {
panic!("unexpected proxy probe")
}
}
@@ -456,7 +456,7 @@ impl Drop for ActiveProbe<'_> {
#[async_trait]
impl ProxyProbe for GatedProxyProbe {
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
async fn test(&self, _: ProbeRoute<'_>, _: bool) -> ProxyTestResult {
self.active
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let _active = ActiveProbe(&self.active);
@@ -526,8 +526,8 @@ async fn batch_tests_share_capacity_preserve_order_and_release_slots_on_cancella
_ = async { receiver.recv().await.unwrap(); receiver.recv().await.unwrap(); } => {}
}
assert_eq!(probe.active.load(Ordering::Relaxed), 2);
let mut single_a = Box::pin(services.proxies().probe(None, false));
let mut single_b = Box::pin(services.proxies().probe(None, false));
let mut single_a = Box::pin(services.proxies().probe(ProbeRoute::Direct, false));
let mut single_b = Box::pin(services.proxies().probe(ProbeRoute::Direct, false));
tokio::select! {
_ = &mut batch => panic!("batch unexpectedly finished"),
_ = &mut single_a => panic!("single probe unexpectedly finished"),
@@ -538,7 +538,7 @@ async fn batch_tests_share_capacity_preserve_order_and_release_slots_on_cancella
assert_eq!(
services
.proxies()
.probe(None, false)
.probe(ProbeRoute::Direct, false)
.await
.unwrap_err()
.kind(),
@@ -100,7 +100,9 @@ fn contract_type(mut item: Item, name: &str) -> Item {
attrs.retain(|attr| attr.path().is_ident("serde"));
attrs.insert(
0,
if name == "SmartSchedulingConfig" {
if name == "ResetWindowWeights" {
parse_quote!(#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)])
} else if name == "SmartSchedulingConfig" {
parse_quote!(#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)])
} else if comparable {
parse_quote!(#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)])
@@ -256,7 +258,7 @@ fn sdk_settings_contract_matches_host_declarations() {
),
(
"../gateway-core/src/account/smart_scheduling.rs",
&["SmartSchedulingValues"][..],
&["SmartSchedulingValues", "ResetWindowWeights"][..],
),
(
"../gateway-core/src/metering/pricing.rs",
@@ -101,6 +101,8 @@ struct TestRequest {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ProbeRequest {
#[serde(default)]
use_deployment_proxy: bool,
#[serde(default)]
detect_location: bool,
proxy_url: AccountProxyUpdate,
@@ -537,10 +539,21 @@ async fn probe<S>(
where
S: SessionState + Send + Sync,
{
use gateway_admin::ports::proxy::ProbeRoute;
let route = match (request.proxy_url.0.as_ref(), request.use_deployment_proxy) {
(Some(_), true) => {
return Err(AdminError::bad_request(
"指定代理地址时不能同时选择部署默认出口",
));
}
(Some(proxy), false) => ProbeRoute::Proxy(proxy),
(None, true) => ProbeRoute::Deployment,
(None, false) => ProbeRoute::Direct,
};
let result = state
.admin_services()
.proxies()
.probe(request.proxy_url.0.as_ref(), request.detect_location)
.probe(route, request.detect_location)
.await
.map_err(map_error)?;
Ok(AdminResponse::new(
@@ -658,7 +658,7 @@ where
let result = state
.admin_services()
.proxies()
.probe(None, true)
.probe(gateway_admin::ports::proxy::ProbeRoute::Direct, true)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
@@ -11,11 +11,11 @@ use chrono::Utc;
use gateway_admin::{
model::{MutationContext, Revision, proxies::*},
ports::{
proxy::{ProxyProbe, ProxyStore},
proxy::{ProbeRoute, ProxyProbe, ProxyStore},
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult},
},
};
use gateway_core::account::{OutboundProxy, ProviderAccountId};
use gateway_core::account::ProviderAccountId;
use serde_json::{Value, json};
use tower::ServiceExt as _;
@@ -348,17 +348,17 @@ pub(super) struct SuccessfulProbe;
#[async_trait]
impl ProxyProbe for SuccessfulProbe {
async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult {
if let Some(proxy) = proxy {
async fn test(&self, proxy: ProbeRoute<'_>, detect_location: bool) -> ProxyTestResult {
if let ProbeRoute::Proxy(proxy) = proxy {
assert_eq!(
proxy.expose_url(),
"http://test-user:private-password@proxy.example:8080/"
);
}
let exit_ip = if proxy.is_some() {
"203.0.113.2"
} else {
"203.0.113.3"
let exit_ip = match proxy {
ProbeRoute::Proxy(_) => "203.0.113.2",
ProbeRoute::Direct => "203.0.113.3",
ProbeRoute::Deployment => "203.0.113.4",
};
ProxyTestResult {
location: if detect_location {
@@ -600,6 +600,15 @@ async fn proxy_probe_checks_direct_egress_without_changing_saved_proxies() {
}
);
}
let (status, deployed) = request(
&fixture,
"/api/admin/proxies/probe",
Some(json!({"proxyUrl": "", "useDeploymentProxy": true})),
true,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(deployed["data"]["exitIpv4"], "203.0.113.4");
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
assert_eq!(listed["data"]["page"]["total"], 1);
assert_eq!(
@@ -613,6 +622,10 @@ async fn proxy_probe_rejects_missing_or_invalid_addresses() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
for (body, expected) in [
(
json!({"proxyUrl": "http://proxy.example:8080", "useDeploymentProxy": true}),
StatusCode::BAD_REQUEST,
),
(json!({"proxyUrl": " "}), StatusCode::UNPROCESSABLE_ENTITY),
(json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY),
(json!({}), StatusCode::UNPROCESSABLE_ENTITY),
@@ -268,7 +268,7 @@ async fn smart_settings_round_trip_and_invalid_updates_leave_the_saved_value_int
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut body = update_body();
let custom = json!({"loadWeight": 2.0, "quotaWeight": 0.0, "healthWeight": 1.0, "latencyWeight": 0.5, "resetWeight": 1.2, "queueWeight": 2.3});
let custom = json!({"loadWeight": 2.0, "quotaWeight": 0.0, "healthWeight": 1.0, "latencyWeight": 0.5, "resetWeight": 1.2, "queueWeight": 2.3, "resetWindowWeights":{"fiveHour":0.0,"weekly":1.5,"monthly":0.0}});
body["smartScheduling"] = custom.clone();
let response = app(fixture.state())
.oneshot(request(
@@ -301,6 +301,19 @@ async fn smart_settings_round_trip_and_invalid_updates_leave_the_saved_value_int
missing.as_object_mut().unwrap().remove(field);
invalid_values.push(missing);
}
for windows in [
json!({}),
json!({"weekly":1}),
json!(null),
json!({"fiveHour":0,"weekly":1.23,"monthly":0}),
json!({"fiveHour":-1,"weekly":1,"monthly":0}),
json!({"fiveHour":0,"weekly":1,"monthly":10.1}),
json!({"fiveHour":0,"weekly":1,"monthly":0,"daily":1}),
] {
let mut invalid = custom.clone();
invalid["resetWindowWeights"] = windows;
invalid_values.push(invalid);
}
for invalid in invalid_values {
body["smartScheduling"] = invalid;
let response = app(fixture.state())
@@ -27,7 +27,7 @@ pub use error::CredentialError;
pub use model::*;
pub(crate) use selection::smart_score;
pub use selection::*;
pub use smart_scheduling::{SmartSchedulingConfig, SmartSchedulingConfigError};
pub use smart_scheduling::{QuotaResetPeriod, SmartSchedulingConfig, SmartSchedulingConfigError};
pub use store::{
AccountCatalogObservation, AccountCatalogOmissions, ProviderAccountStore, StoredAccountCatalog,
};
@@ -3,7 +3,7 @@
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, RwLock};
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime};
use futures::FutureExt;
@@ -245,6 +245,7 @@ pub struct AccountRuntimeSignals {
pub in_flight: u32,
pub last_started_at: Option<SystemTime>,
pub quota_reset_at: Option<SystemTime>,
pub quota_reset_windows: Option<Arc<[AccountQuotaResetWindow]>>,
/// Provider 归一化的剩余额度基点:0 耗尽,10_000 全部可用
pub quota_remaining_rank: Option<u64>,
pub cooldown: Option<super::AccountCooldown>,
@@ -459,6 +460,7 @@ impl AccountRuntimeSignals {
pub fn with_provider_quota(mut self, quota: Option<AccountQuotaSignals>) -> Self {
if let Some(quota) = quota {
self.quota_reset_at = quota.reset_at;
self.quota_reset_windows = quota.reset_windows;
self.quota_remaining_rank = quota.remaining_rank;
}
self
@@ -482,9 +484,17 @@ impl AccountRuntimeSignals {
}
}
/// Provider 确认为账号通用且属于受支持周期的重置观测
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountQuotaResetWindow {
pub period: super::QuotaResetPeriod,
pub reset_at: SystemTime,
}
/// Provider 从私有 quota JSON 投影出的中立调度事实
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AccountQuotaSignals {
reset_windows: Option<Arc<[AccountQuotaResetWindow]>>,
reset_at: Option<SystemTime>,
remaining_rank: Option<u64>,
}
@@ -496,16 +506,23 @@ impl AccountQuotaSignals {
Self {
reset_at,
remaining_rank,
reset_windows: None,
}
}
#[must_use]
pub const fn reset_at(self) -> Option<SystemTime> {
pub fn with_reset_windows(mut self, windows: Vec<AccountQuotaResetWindow>) -> Self {
self.reset_windows = (!windows.is_empty()).then(|| windows.into());
self
}
#[must_use]
pub const fn reset_at(&self) -> Option<SystemTime> {
self.reset_at
}
#[must_use]
pub const fn remaining_rank(self) -> Option<u64> {
pub const fn remaining_rank(&self) -> Option<u64> {
self.remaining_rank
}
}
@@ -940,8 +957,6 @@ impl AccountSelector {
// 首输出 10 秒时延迟得分减半;固定尺度不随其他候选账号变化
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, context: &AccountSelectionContext) -> f64 {
context
@@ -997,15 +1012,6 @@ pub(crate) fn smart_score(candidate: &AccountCandidate, context: &AccountSelecti
SMART_LATENCY_HALF_SCORE_MS / (SMART_LATENCY_HALF_SCORE_MS + latency as f64)
});
let reset = candidate
.signals
.quota_reset_at
.and_then(|reset| reset.duration_since(now).ok())
.filter(|remaining| !remaining.is_zero())
.map_or(0.0, |remaining| {
SMART_RESET_HALF_SCORE_SECONDS
/ (SMART_RESET_HALF_SCORE_SECONDS + remaining.as_secs_f64())
});
let [
load_weight,
quota_weight,
@@ -1018,5 +1024,5 @@ pub(crate) fn smart_score(candidate: &AccountCandidate, context: &AccountSelecti
+ quota_weight * quota
+ health_weight * failure
+ latency_weight * latency
+ reset_weight * reset
+ reset_weight * config.reset_score(&candidate.signals, now)
}
@@ -1,11 +1,40 @@
//! 智能调度参数;固定十分位保证快照可精确比较,浮点数只用于 wire 与评分
use std::time::SystemTime;
use super::AccountRuntimeSignals;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(into = "SmartSchedulingValues")]
pub struct SmartSchedulingConfig {
weights: [u8; 6],
reset_windows: [u8; 3],
}
/// 账号通用额度的周期;Provider 按上游周期事实投影,不依赖窗口名称和槽位
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuotaResetPeriod {
FiveHour,
Weekly,
Monthly,
}
impl QuotaResetPeriod {
const fn seconds(self) -> u32 {
match self {
Self::FiveHour => 18_000,
Self::Weekly => 604_800,
Self::Monthly => 2_592_000,
}
}
}
fn weight_tenths(value: f64, field: &'static str) -> Result<u8, SmartSchedulingConfigError> {
if !(0.0..=10.0).contains(&value) || (value * 10.0).round() / 10.0 != value {
return Err(SmartSchedulingConfigError::InvalidWeight(field));
}
Ok((value * 10.0).round() as u8)
}
impl<'de> Deserialize<'de> for SmartSchedulingConfig {
@@ -23,6 +52,7 @@ impl Default for SmartSchedulingConfig {
fn default() -> Self {
Self {
weights: [10, 8, 10, 5, 0, 0],
reset_windows: [0; 3],
}
}
}
@@ -50,19 +80,15 @@ impl SmartSchedulingConfig {
.into_iter()
.enumerate()
{
let value = weights[index];
if !value.is_finite()
|| !(0.0..=10.0).contains(&value)
|| (value * 10.0).round() / 10.0 != value
{
return Err(SmartSchedulingConfigError::InvalidWeight(field));
}
tenths[index] = (value * 10.0).round() as u8;
tenths[index] = weight_tenths(weights[index], field)?;
}
if tenths == [0; 6] {
return Err(SmartSchedulingConfigError::EmptyWeights);
}
Ok(Self { weights: tenths })
Ok(Self {
weights: tenths,
..Self::default()
})
}
#[must_use]
@@ -70,11 +96,44 @@ impl SmartSchedulingConfig {
self.weights.map(|weight| f64::from(weight) / 10.0)
}
fn reset_enabled(self) -> bool {
self.weights[4] > 0 && self.reset_windows != [0; 3]
}
pub(crate) fn reset_score(self, signals: &AccountRuntimeSignals, now: SystemTime) -> f64 {
if !self.reset_enabled() {
return 0.0;
}
let mut scores = [0.0_f64; 3];
for window in signals.quota_reset_windows.as_deref().unwrap_or_default() {
let Ok(remaining) = window.reset_at.duration_since(now) else {
continue;
};
if remaining.is_zero() {
continue;
}
let half = f64::from(window.period.seconds()) / 7.0;
let score = half / (half + remaining.as_secs_f64());
// 同一周期只计最近的有效重置,避免重复额度桶叠加得分
let slot = &mut scores[window.period as usize];
*slot = slot.max(score);
}
// 缺失周期得零分,分母固定,避免少报窗口反而提高排名
let weights = self.reset_windows.map(f64::from);
scores
.into_iter()
.zip(weights)
.map(|(score, w)| score * w)
.sum::<f64>()
/ weights.into_iter().sum::<f64>()
}
pub(crate) fn score_tolerance(self) -> f64 {
// 与系数同步缩放,避免仅放大相同比例的系数就改变近似最优候选集合
// 排队系数只参与入队选择,不能放大立即选号的候选容差
// 排队和未启用的重置窗口不能放大立即选号的容差
let count = if self.reset_enabled() { 5 } else { 4 };
0.05 * f64::from(
self.weights[..5]
self.weights[..count]
.iter()
.copied()
.map(u16::from)
@@ -92,20 +151,37 @@ struct SmartSchedulingValues {
latency_weight: f64,
reset_weight: f64,
queue_weight: f64,
#[serde(default)]
reset_window_weights: ResetWindowWeights,
}
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ResetWindowWeights {
five_hour: f64,
weekly: f64,
monthly: f64,
}
impl TryFrom<SmartSchedulingValues> for SmartSchedulingConfig {
type Error = SmartSchedulingConfigError;
fn try_from(value: SmartSchedulingValues) -> Result<Self, Self::Error> {
Self::new([
let mut config = Self::new([
value.load_weight,
value.quota_weight,
value.health_weight,
value.latency_weight,
value.reset_weight,
value.queue_weight,
])
])?;
let windows = value.reset_window_weights;
config.reset_windows = [
weight_tenths(windows.five_hour, "fiveHour")?,
weight_tenths(windows.weekly, "weekly")?,
weight_tenths(windows.monthly, "monthly")?,
];
Ok(config)
}
}
@@ -126,6 +202,11 @@ impl From<SmartSchedulingConfig> for SmartSchedulingValues {
latency_weight,
reset_weight,
queue_weight,
reset_window_weights: ResetWindowWeights {
five_hour: f64::from(value.reset_windows[0]) / 10.0,
weekly: f64::from(value.reset_windows[1]) / 10.0,
monthly: f64::from(value.reset_windows[2]) / 10.0,
},
}
}
}
@@ -56,6 +56,7 @@ fn candidate(id: &str, in_flight: u32, remaining: Option<u64>) -> AccountCandida
in_flight,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: remaining,
cooldown: None,
failure_rate_basis_points: None,
@@ -228,6 +229,7 @@ fn diagnostic_selection_bypasses_all_local_account_eligibility() {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -247,6 +249,7 @@ fn diagnostic_selection_bypasses_all_local_account_eligibility() {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -641,6 +644,7 @@ fn provider_quota_overlay_should_preserve_store_concurrency_facts() {
in_flight: 2,
last_started_at: Some(last_started_at),
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -1,7 +1,8 @@
//! 验证智能调度权重、软亲和与容量约束的组合行为
use gateway_core::account::{
AccountSelector, PreferredAccountSelection, RotationStrategy, SmartSchedulingConfig,
AccountQuotaResetWindow, AccountSelector, PreferredAccountSelection, QuotaResetPeriod,
RotationStrategy, SmartSchedulingConfig,
};
use serde_json::json;
use std::time::{Duration, Instant, SystemTime};
@@ -62,9 +63,10 @@ fn smart_config_rejects_invalid_weights_and_incomplete_wire_values() {
#[test]
fn reset_weight_rewards_only_future_resets_and_preserves_capacity_checks() {
let mut context = context(RotationStrategy::Smart);
context.policy = context
.policy
.with_smart_scheduling(SmartSchedulingConfig::new([0.0, 0.0, 0.0, 0.0, 1.0, 0.0]).unwrap());
context.policy = context.policy.with_smart_scheduling(serde_json::from_value(json!({
"loadWeight":0, "quotaWeight":0, "healthWeight":0, "latencyWeight":0, "resetWeight":1, "queueWeight":0,
"resetWindowWeights":{"fiveHour":0,"weekly":1,"monthly":0}
})).unwrap());
let mut candidates = [
candidate_with_concurrency("acct_unknown", 0, 2),
candidate_with_concurrency("acct_past", 0, 2),
@@ -72,10 +74,20 @@ fn reset_weight_rewards_only_future_resets_and_preserves_capacity_checks() {
candidate_with_concurrency("acct_near", 0, 2),
candidate_with_concurrency("acct_far", 0, 2),
];
candidates[1].signals.quota_reset_at = Some(context.now - Duration::from_secs(1));
candidates[2].signals.quota_reset_at = Some(context.now);
candidates[3].signals.quota_reset_at = Some(context.now + Duration::from_secs(3600));
candidates[4].signals.quota_reset_at = Some(context.now + Duration::from_secs(86_400));
for (candidate, reset_at) in candidates[1..].iter_mut().zip([
context.now - Duration::from_secs(1),
context.now,
context.now + Duration::from_secs(3600),
context.now + Duration::from_secs(86_400),
]) {
candidate.signals.quota_reset_windows = Some(
[AccountQuotaResetWindow {
period: QuotaResetPeriod::Weekly,
reset_at,
}]
.into(),
);
}
assert_eq!(
AccountSelector
.select(&candidates, &context)
@@ -92,8 +104,8 @@ fn reset_weight_rewards_only_future_resets_and_preserves_capacity_checks() {
let scores = snapshot["events"][0]["data"]["candidates"]
.as_array()
.unwrap();
for (candidate, expected) in scores.iter().zip([0.0, 0.0, 0.0, 0.5, 0.04]) {
assert_eq!(candidate["smartScore"].as_f64().unwrap(), expected);
for (candidate, expected) in scores.iter().zip([0.0, 0.0, 0.0, 0.96, 0.5]) {
assert!((candidate["smartScore"].as_f64().unwrap() - expected).abs() < 1e-10);
}
candidates[3].signals.in_flight = 2;
assert_eq!(
@@ -120,6 +132,36 @@ fn reset_weight_rewards_only_future_resets_and_preserves_capacity_checks() {
);
}
#[test]
fn zero_or_absent_window_weights_disable_reset_scores_and_tolerance() {
let mut context = context(RotationStrategy::Smart);
let mut candidate = candidate_with_concurrency("acct_reset", 0, 2);
candidate.signals.quota_reset_windows = Some(
[AccountQuotaResetWindow {
period: QuotaResetPeriod::Monthly,
reset_at: context.now + Duration::from_secs(1),
}]
.into(),
);
let legacy = json!({"loadWeight":0,"quotaWeight":0,"healthWeight":0,"latencyWeight":0,"resetWeight":10,"queueWeight":0});
let mut disabled = legacy.clone();
disabled["resetWindowWeights"] = json!({"fiveHour":0,"weekly":0,"monthly":0});
for wire in [legacy, disabled] {
let config: SmartSchedulingConfig = serde_json::from_value(wire).unwrap();
context.policy = context.policy.with_smart_scheduling(config);
let trace = gateway_core::diagnostics::TraceContext::new("req_zero_windows");
trace.account_selection(std::slice::from_ref(&candidate), &context, None);
let events = trace.snapshot().unwrap();
let data = &events["events"][0]["data"];
assert_eq!(data["candidates"][0]["smartScore"], 0.0);
assert_eq!(data["smartScoreTolerance"], 0.0);
assert_eq!(
serde_json::to_value(config).unwrap()["resetWindowWeights"],
json!({"fiveHour":0.0,"weekly":0.0,"monthly":0.0})
);
}
}
#[test]
fn queue_weight_balances_live_pressure_and_health_only_when_enabled_for_smart() {
for (strategy, queue_weight, expects_shortest) in [
@@ -349,3 +391,41 @@ fn higher_weight_recovery_preserves_usable_soft_affinity() {
);
}
}
#[test]
fn missing_expired_and_duplicate_periods_do_not_inflate_reset_score() {
let mut context = context(RotationStrategy::Smart);
let mut candidate = candidate_with_concurrency("acct_partial", 0, 2);
candidate.signals.quota_reset_at = Some(context.now + Duration::from_secs(1));
candidate.signals.quota_reset_windows = Some(
[
(QuotaResetPeriod::Weekly, 86400),
(QuotaResetPeriod::Weekly, 90000),
(QuotaResetPeriod::Weekly, 0),
(QuotaResetPeriod::FiveHour, 0),
]
.map(|(period, delta)| AccountQuotaResetWindow {
period,
reset_at: context.now + Duration::from_secs(delta),
})
.into(),
);
for scale in [1.0, 0.5] {
let mut config = serde_json::to_value(
SmartSchedulingConfig::new([0.0, 0.0, 0.0, 0.0, 1.0, 0.0]).unwrap(),
)
.unwrap();
config["resetWindowWeights"] = json!({
"weekly": 2.0*scale, "fiveHour": 4.0*scale, "monthly": 4.0*scale
});
context.policy = context
.policy
.with_smart_scheduling(serde_json::from_value(config).unwrap());
let trace = gateway_core::diagnostics::TraceContext::new("req_partial");
trace.account_selection(std::slice::from_ref(&candidate), &context, None);
assert_eq!(
trace.snapshot().unwrap()["events"][0]["data"]["candidates"][0]["smartScore"],
0.1
);
}
}
@@ -2324,6 +2324,7 @@ impl Provider for PolicySelectingProvider {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -77,6 +77,7 @@ fn candidate(id: &str) -> AccountCandidate {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -200,6 +200,7 @@ fn scheduling_state_preserves_provider_neutral_signals() {
in_flight: 2,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: Some(7),
cooldown: None,
failure_rate_basis_points: Some(125),
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "gateway-egress"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
rust-version.workspace = true
[dependencies]
gateway-core = { path = "../gateway-core" }
http = "1.4.0"
hyper-util = { version = "=0.1.21", features = ["client-proxy"] }
reqwest = { version = "=0.12.28", default-features = false, features = ["socks"] }
url = "2.5"
[lints]
workspace = true
+117
View File
@@ -0,0 +1,117 @@
//! 网关 HTTP 与 WebSocket 出口:业务指定的代理优先,否则按目的地址使用部署环境
use std::sync::OnceLock;
use gateway_core::account::{InvalidOutboundProxy, OutboundProxy};
use hyper_util::client::proxy::matcher::Matcher;
use reqwest::{Client, ClientBuilder, NoProxy, Proxy};
use url::Url;
struct Environment {
http: Option<OutboundProxy>,
https: Option<OutboundProxy>,
no_proxy: String,
matcher: Matcher,
}
impl Environment {
fn load() -> Result<Self, InvalidOutboundProxy> {
let var = |name: &str| {
std::env::var(name)
.or_else(|_| std::env::var(name.to_ascii_lowercase()))
.ok()
.filter(|value| !value.is_empty())
};
// CGI 的 HTTP_* 来自入站请求头,不能作为部署配置
let no_proxy = var("NO_PROXY").unwrap_or_default();
let bypass_all = std::env::var_os("REQUEST_METHOD").is_some()
|| no_proxy.split(',').any(|entry| entry.trim() == "*");
let all = var("ALL_PROXY");
let route = |name| {
if bypass_all {
return Ok(None);
}
var(name)
.or_else(|| all.clone())
.map(|value| {
// 部署环境也接受常见的 host:port 写法,服务内保存的代理仍须是完整 URL
if value.contains("://") {
OutboundProxy::parse(&value)
} else {
OutboundProxy::parse(&format!("http://{value}"))
}
})
.transpose()
};
let http = route("HTTP_PROXY")?;
let https = route("HTTPS_PROXY")?;
let matcher = Matcher::builder()
.http(http.as_ref().map_or("", OutboundProxy::expose_url))
.https(https.as_ref().map_or("", OutboundProxy::expose_url))
.no(no_proxy.as_str())
.build();
Ok(Self {
http,
https,
no_proxy,
matcher,
})
}
}
fn environment() -> Result<&'static Environment, InvalidOutboundProxy> {
static ENV: OnceLock<Result<Environment, InvalidOutboundProxy>> = OnceLock::new();
ENV.get_or_init(Environment::load).as_ref().map_err(|e| *e)
}
/// 每个请求(包括重定向)按目标匹配环境代理;显式代理不受 NO_PROXY 影响
pub fn http_client(proxy: Option<&OutboundProxy>) -> Result<ClientBuilder, InvalidOutboundProxy> {
let mut builder = Client::builder().no_proxy();
if let Some(proxy) = proxy {
return Ok(builder.proxy(Proxy::all(proxy.expose_url()).map_err(|_| InvalidOutboundProxy)?));
}
let env = environment()?;
let bypass = NoProxy::from_string(&env.no_proxy);
if let Some(proxy) = &env.http {
builder = builder.proxy(
Proxy::http(proxy.expose_url())
.map_err(|_| InvalidOutboundProxy)?
.no_proxy(bypass.clone()),
);
}
if let Some(proxy) = &env.https {
builder = builder.proxy(
Proxy::https(proxy.expose_url())
.map_err(|_| InvalidOutboundProxy)?
.no_proxy(bypass),
);
}
Ok(builder)
}
/// 自行拨号的传输复用 reqwest 使用的匹配器,但保留原代理的凭据与连接限制
pub fn for_destination<'a>(
proxy: Option<&'a OutboundProxy>,
url: &Url,
) -> Result<Option<&'a OutboundProxy>, InvalidOutboundProxy> {
if proxy.is_some() {
return Ok(proxy);
}
let env = environment()?;
let (scheme, proxy) = match url.scheme() {
"http" | "ws" => ("http", env.http.as_ref()),
"https" | "wss" => ("https", env.https.as_ref()),
_ => return Err(InvalidOutboundProxy),
};
let Some(proxy) = proxy else {
return Ok(None);
};
let authority = &url[url::Position::BeforeHost..url::Position::AfterPort];
let uri = http::Uri::builder()
.scheme(scheme)
.authority(authority)
.path_and_query("/")
.build()
.map_err(|_| InvalidOutboundProxy)?;
Ok(env.matcher.intercept(&uri).map(|_| proxy))
}
+1
View File
@@ -17,6 +17,7 @@ config = { version = "0.15.23", default-features = false, features = ["yaml"] }
flate2 = "1.1.9"
futures = "0.3.32"
gateway-admin = { path = "../gateway-admin" }
gateway-egress = { path = "../gateway-egress" }
gateway-core = { path = "../gateway-core" }
gateway-protocol = { path = "../gateway-protocol" }
hex = "0.4.3"
@@ -52,17 +52,19 @@ struct CachedDownloads {
impl RgAdguardClientDistribution {
pub(crate) fn new() -> Self {
let client = Client::builder()
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.redirect(Policy::none())
.build()
.inspect_err(|_| {
tracing::warn!(
"download resolver HTTP client initialization failed; official downloads will be used"
);
})
.ok();
let client = gateway_egress::http_client(None).ok().and_then(|builder| {
builder
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(30))
.redirect(Policy::none())
.build()
.ok()
});
if client.is_none() {
tracing::warn!(
"download resolver HTTP client initialization failed; official downloads will be used"
);
}
Self {
client,
cache: Mutex::new(None),
@@ -216,6 +216,8 @@ impl HttpClient {
{
return Err(HttpError::invalid("URL"));
}
let proxy = gateway_egress::for_destination(proxy, &url)
.map_err(|_| HttpError::invalid("proxy"))?;
let method = Method::from_bytes(request.method.as_bytes())
.map_err(|_| HttpError::invalid("method"))?;
if matches!(method, Method::CONNECT | Method::TRACE) {
+2 -8
View File
@@ -37,17 +37,11 @@ impl PeerTransport for HttpPeerTransport {
account: &ProviderAccount,
authentication: &PeerConnection,
) -> Result<Vec<u8>, PeerFetchFailure> {
let mut builder = reqwest::Client::builder()
.no_proxy()
let builder = gateway_egress::http_client(account.outbound_proxy())
.map_err(|_| UpstreamInfoFailure::Unavailable)?
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(REQUEST_TIMEOUT)
.timeout(REQUEST_TIMEOUT);
if let Some(proxy) = account.outbound_proxy() {
builder = builder.proxy(
reqwest::Proxy::all(proxy.expose_url())
.map_err(|_| UpstreamInfoFailure::Unavailable)?,
);
}
let client = (self.build_client)(builder).map_err(|_| UpstreamInfoFailure::Unavailable)?;
let response = client
.get(format!(
+2 -1
View File
@@ -17,7 +17,8 @@ pub struct ModelsDevPricing;
#[async_trait]
impl PricingSource for ModelsDevPricing {
async fn fetch(&self) -> Result<PricingSyncPreview, AdminError> {
let client = reqwest::Client::builder()
let client = gateway_egress::http_client(None)
.map_err(|_| source_error())?
.https_only(true)
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(10))
+29 -30
View File
@@ -1,4 +1,4 @@
//! 使用与 Provider 请求一致的显式代理或直连路径,执行有超时和响应大小限制的出口测试
//! 按指定出口,执行有超时和响应大小限制的出口测试
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
@@ -9,11 +9,20 @@ use std::{
use async_trait::async_trait;
use gateway_admin::{
model::proxies::{ProxyLocationDetection, ProxyTestResult},
ports::proxy::ProxyProbe,
ports::proxy::{ProbeRoute, ProxyProbe},
};
use gateway_core::account::{OutboundProxy, RequestLocation};
use gateway_core::account::RequestLocation;
use serde::Deserialize;
fn probe_client(route: ProbeRoute<'_>) -> Result<reqwest::ClientBuilder, &'static str> {
match route {
ProbeRoute::Direct => Ok(reqwest::Client::builder().no_proxy()),
ProbeRoute::Deployment => gateway_egress::http_client(None),
ProbeRoute::Proxy(proxy) => gateway_egress::http_client(Some(proxy)),
}
.map_err(|_| "代理地址不合法")
}
enum ProbeStrategy {
Single(String),
Dual {
@@ -75,18 +84,13 @@ impl HttpProxyProbe {
async fn exit_ip_at(
&self,
proxy: Option<&OutboundProxy>,
proxy: ProbeRoute<'_>,
endpoint: &str,
) -> Result<IpAddr, &'static str> {
let mut builder = reqwest::Client::builder()
.no_proxy()
let builder = probe_client(proxy)?
.connect_timeout(Duration::from_secs(5))
.timeout(Duration::from_secs(12))
.redirect(reqwest::redirect::Policy::none());
if let Some(proxy) = proxy {
builder = builder
.proxy(reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?);
}
let client = (self.build_client)(builder)?;
let mut response = client.get(endpoint).send().await.map_err(|error| {
if error.is_timeout() {
@@ -125,7 +129,7 @@ impl HttpProxyProbe {
impl HttpProxyProbe {
async fn timed_exit_ip_at(
&self,
proxy: Option<&OutboundProxy>,
proxy: ProbeRoute<'_>,
endpoint: &str,
) -> (Result<IpAddr, &'static str>, u64) {
let started = Instant::now();
@@ -134,7 +138,7 @@ impl HttpProxyProbe {
(result, latency_ms)
}
async fn test_connection(&self, proxy: Option<&OutboundProxy>) -> ProxyTestResult {
async fn test_connection(&self, proxy: ProbeRoute<'_>) -> ProxyTestResult {
let started = Instant::now();
let timeout_limit = Duration::from_secs(15);
@@ -276,39 +280,34 @@ impl HttpProxyProbe {
async fn location_for_ip(
&self,
proxy: Option<&OutboundProxy>,
proxy: ProbeRoute<'_>,
ip: IpAddr,
) -> Result<RequestLocation, &'static str> {
// 出口 IP 已由探测确认;按这个固定 IP 从服务端查询位置,避免代理出口屏蔽位置服务
// 部署环境无法直连位置服务时仍尝试原代理路径
match self.lookup_location(ip, None).await {
// 查询的是已经验证的 IP,不要求被测代理能访问位置服务。
// 服务器直连探测保持直连,其余探测先按部署环境查询,必要时再用被测代理。
if !matches!(proxy, ProbeRoute::Proxy(_)) {
return self.lookup_location(ip, proxy).await;
}
match self.lookup_location(ip, ProbeRoute::Deployment).await {
Ok(location) => Ok(location),
Err(direct_error) if proxy.is_some() => {
self.lookup_location(ip, proxy).await.or(Err(direct_error))
}
Err(error) => Err(error),
Err(error) => self.lookup_location(ip, proxy).await.or(Err(error)),
}
}
async fn lookup_location(
&self,
ip: IpAddr,
proxy: Option<&OutboundProxy>,
proxy: ProbeRoute<'_>,
) -> Result<RequestLocation, &'static str> {
let timeout = if proxy.is_some() {
let timeout = if !matches!(proxy, ProbeRoute::Direct) {
Duration::from_secs(4)
} else {
Duration::from_secs(3)
};
let mut builder = reqwest::Client::builder()
.no_proxy()
let builder = probe_client(proxy)?
.connect_timeout(timeout)
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none());
if let Some(proxy) = proxy {
builder = builder
.proxy(reqwest::Proxy::all(proxy.expose_url()).map_err(|_| "代理地址不合法")?);
}
let client = (self.build_client)(builder)?;
// 查询已检测到的具体出口,不能再次查询“我的 IP”,轮换代理可能换到另一个出口
let url = format!("{}/{ip}", self.location_endpoint.trim_end_matches('/'));
@@ -367,7 +366,7 @@ impl HttpProxyProbe {
async fn detect_location(
&self,
proxy: Option<&OutboundProxy>,
proxy: ProbeRoute<'_>,
result: &ProxyTestResult,
) -> ProxyLocationDetection {
let lookup = async |ip: Option<IpAddr>| match ip {
@@ -397,7 +396,7 @@ impl HttpProxyProbe {
#[async_trait]
impl ProxyProbe for HttpProxyProbe {
async fn test(&self, proxy: Option<&OutboundProxy>, detect_location: bool) -> ProxyTestResult {
async fn test(&self, proxy: ProbeRoute<'_>, detect_location: bool) -> ProxyTestResult {
let mut result = self.test_connection(proxy).await;
if detect_location {
result.location = if result.success {
@@ -132,16 +132,17 @@ impl Metadata {
let client = self
.client
.get_or_try_init(|| async {
Client::builder()
gateway_egress::http_client(None)
.map_err(|_| super::error::invalid("部署代理配置不合法"))?
.user_agent("codex-proxy-rs")
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.redirect(reqwest::redirect::Policy::none())
.retry(reqwest::retry::never())
.build()
.map_err(|e| super::error::http("无法初始化发行查询连接", e))
})
.await
.map_err(|e| super::error::http("无法初始化发行查询连接", e))?;
.await?;
let request = client.get(url);
let mut response = if api {
request.header("Accept", "application/vnd.github+json")
@@ -136,7 +136,8 @@ pub(crate) fn download_client(
timeout: Duration,
) -> Result<reqwest::Client, OperationError> {
let api_base = api_base.to_owned();
reqwest::Client::builder()
gateway_egress::http_client(None)
.map_err(|_| invalid("部署代理配置不合法"))?
.timeout(timeout)
.redirect(reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() < 5 && download_url_allowed(attempt.url(), &api_base) {
@@ -268,7 +268,12 @@ fn move_file(from: &Path, to: &Path) -> io::Result<()> {
fn move_dir(from: &Path, to: &Path) -> io::Result<()> {
match fs::rename(from, to) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::CrossesDevices => {
Err(error)
if error.kind() == io::ErrorKind::CrossesDevices
|| (cfg!(unix) && error.kind() == io::ErrorKind::PermissionDenied) =>
{
// Unix 跨父目录移动只读目录可能被拒绝,复用保留权限的复制路径
// 仅清理源目录时放开写权限,备份与恢复后的目录仍保留原权限
if let Err(copy_error) = copy_dir_all(from, to) {
let _ = remove_dir_all(to);
return Err(copy_error);
@@ -354,3 +354,99 @@ async fn managed_http_enforces_the_callers_body_limit_before_sending() {
assert_eq!(requests[0].body.len(), maximum);
assert_eq!(requests[1].body.len(), maximum + 1);
}
#[tokio::test]
async fn deployment_proxy_routes_managed_http_and_account_probes() {
use gateway_admin::ports::proxy::{ProbeRoute, ProxyProbe};
use gateway_host::proxy_probe::HttpProxyProbe;
if let Ok(origin) = std::env::var("CPR_TEST_EGRESS_ORIGIN") {
let selected =
OutboundProxy::parse(&std::env::var("CPR_TEST_EGRESS_SELECTED").unwrap()).unwrap();
let outside = origin.replace("127.0.0.1", "localhost");
let managed = HttpClient::new().unwrap();
let http = |url: String, proxy| {
let managed = &managed;
async move {
managed
.open(
request(url),
proxy,
&local_network(),
Duration::from_secs(2),
)
.await
.unwrap()
.status
}
};
let probe = HttpProxyProbe::new(&outside);
let observed = |result: gateway_admin::model::proxies::ProxyTestResult| {
result.exit_ipv4.unwrap().octets()[3]
};
assert_eq!(observed(probe.test(ProbeRoute::Direct, false).await), 1);
assert_eq!(http(origin.clone(), Some(&selected)).await, 203);
assert_eq!(http(origin, None).await, 200);
let expect_env = std::env::var_os("HTTP_PROXY").is_some();
assert_eq!(
http(outside, None).await,
if expect_env { 202 } else { 200 }
);
assert_eq!(
observed(probe.test(ProbeRoute::Deployment, false).await),
if expect_env { 2 } else { 1 }
);
println!("checked managed egress");
return;
}
let origin = MockServer::start().await;
let env = MockServer::start().await;
let selected = MockServer::start().await;
for (server, code, ip) in [
(&origin, 200, "192.0.2.1"),
(&env, 202, "192.0.2.2"),
(&selected, 203, "192.0.2.3"),
] {
Mock::given(any())
.respond_with(ResponseTemplate::new(code).set_body_json(serde_json::json!({"ip": ip})))
.mount(server)
.await;
}
for configured in [true, false] {
let mut command = tokio::process::Command::new(std::env::current_exe().unwrap());
for name in [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"REQUEST_METHOD",
] {
command
.env_remove(name)
.env_remove(name.to_ascii_lowercase());
}
if configured {
command
.env("HTTP_PROXY", env.uri())
.env("NO_PROXY", "127.0.0.0/8");
}
let output = command
.args([
"--exact",
"outbound::http::deployment_proxy_routes_managed_http_and_account_probes",
"--nocapture",
])
.env("CPR_TEST_EGRESS_ORIGIN", origin.uri())
.env("CPR_TEST_EGRESS_SELECTED", selected.uri())
.output()
.await
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("checked managed egress"));
}
}
@@ -1,6 +1,6 @@
//! 验证代理及直连探测的双栈地址、认证、证书与失败边界
use gateway_admin::ports::proxy::ProxyProbe;
use gateway_admin::ports::proxy::{ProbeRoute, ProxyProbe};
use gateway_core::account::OutboundProxy;
use gateway_host::proxy_probe::HttpProxyProbe;
use serde_json::json;
@@ -28,7 +28,7 @@ async fn proxy_probe_supports_ipv4_and_ipv6_proxies_and_exit_addresses() {
OutboundProxy::parse(&format!("http://user:password@{}", proxy_server.address()))
.unwrap();
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(Some(&proxy), false)
.test(ProbeRoute::Proxy(&proxy), false)
.await;
assert!(
result.success,
@@ -55,7 +55,7 @@ async fn proxy_probe_rejects_auth_errors_redirects_and_invalid_or_oversized_resp
.await;
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
ProbeRoute::Proxy(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
false,
)
.await;
@@ -76,7 +76,7 @@ async fn unavailable_proxy_never_falls_back_to_direct_connection() {
let proxy = OutboundProxy::parse(&format!("http://{}", unused.local_addr().unwrap())).unwrap();
drop(unused);
let result = HttpProxyProbe::new(target.uri())
.test(Some(&proxy), false)
.test(ProbeRoute::Proxy(&proxy), false)
.await;
assert!(!result.success);
}
@@ -91,7 +91,10 @@ async fn invalid_certificate_configuration_should_not_fall_back_or_expose_detail
.await;
let result = HttpProxyProbe::new(target.uri())
.with_client_builder(|_| Err("private-certificate-path"))
.test(Some(&OutboundProxy::parse(&target.uri()).unwrap()), false)
.test(
ProbeRoute::Proxy(&OutboundProxy::parse(&target.uri()).unwrap()),
false,
)
.await;
assert!(!result.success);
assert!(!result.message.contains("private-certificate-path"));
@@ -117,7 +120,7 @@ async fn dual_stack_proxy_probe_reports_both_addresses_when_available() {
format!("{}/v4", proxy_server.uri()),
format!("{}/v6", proxy_server.uri()),
)
.test(Some(&proxy), false)
.test(ProbeRoute::Proxy(&proxy), false)
.await;
assert!(result.success);
@@ -146,7 +149,7 @@ async fn dual_stack_proxy_probe_reports_single_stack_when_only_one_succeeds() {
format!("{}/v4", proxy_server.uri()),
format!("{}/v6", proxy_server.uri()),
)
.test(Some(&proxy), false)
.test(ProbeRoute::Proxy(&proxy), false)
.await;
assert!(result.success);
@@ -178,7 +181,7 @@ async fn dual_stack_latency_measures_the_selected_exit_without_the_other_family_
format!("{}/v4", server.uri()),
format!("{}/v6", server.uri()),
)
.test(None, false)
.test(ProbeRoute::Direct, false)
.await;
assert!(result.success);
assert_eq!(result.exit_ipv4.is_some(), working_family == "v4");
@@ -229,7 +232,7 @@ async fn direct_location_uses_verified_addresses_and_rejects_conflicting_timezon
format!("{}/v6", server.uri()),
)
.with_location_endpoint(format!("{}/geo", server.uri()))
.test(None, true)
.test(ProbeRoute::Direct, true)
.await;
assert!(result.success);
if v6_timezone == Some("America/New_York") {
@@ -261,7 +264,7 @@ async fn direct_location_failure_does_not_retry_the_same_path_or_return_a_locati
.await;
let result = HttpProxyProbe::new(format!("{}/ip", server.uri()))
.with_location_endpoint(format!("{}/geo", server.uri()))
.test(None, true)
.test(ProbeRoute::Direct, true)
.await;
assert_eq!(
result.location,
@@ -294,7 +297,7 @@ async fn location_lookup_uses_verified_exit_ip_without_requiring_proxy_access_to
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_location_endpoint(format!("{}/geo", location_server.uri()))
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
ProbeRoute::Proxy(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
true,
)
.await;
@@ -333,7 +336,7 @@ async fn location_lookup_uses_probed_ips_through_the_proxy_and_checks_both_timez
)
.with_location_endpoint("http://unresolvable.invalid/geo/")
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
ProbeRoute::Proxy(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
true,
)
.await;
@@ -379,7 +382,7 @@ async fn geolocation_failure_never_turns_connectivity_into_failure_or_uses_unver
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_location_endpoint("http://unresolvable.invalid/geo")
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
ProbeRoute::Proxy(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
true,
)
.await;
@@ -407,7 +410,7 @@ async fn disabled_geolocation_never_calls_the_location_service() {
let result = HttpProxyProbe::new("http://unresolvable.invalid/ip")
.with_location_endpoint("http://unresolvable.invalid/geo")
.test(
Some(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
ProbeRoute::Proxy(&OutboundProxy::parse(&proxy_server.uri()).unwrap()),
false,
)
.await;
@@ -437,6 +437,13 @@ async fn restart_should_conflict_while_another_system_operation_is_running() {
async fn rollback_should_restore_binary_web_and_version_state() {
let server = MockServer::start().await;
let fixture = Fixture::new();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(fixture.official(), fs::Permissions::from_mode(0o555)).unwrap();
fs::set_permissions(fixture.web(), fs::Permissions::from_mode(0o751)).unwrap();
}
fixture
.mount_release(
&server,
@@ -470,6 +477,14 @@ async fn rollback_should_restore_binary_web_and_version_state() {
& 0o777,
0o555
);
assert_eq!(
fs::metadata(fixture.official().with_file_name("official.backup"))
.expect("official backup directory")
.permissions()
.mode()
& 0o777,
0o555
);
for name in ["plugin-release-manifest.json", "new-plugin.tar.gz"] {
assert_eq!(
fs::metadata(fixture.official().join(name))
@@ -487,6 +502,18 @@ async fn rollback_should_restore_binary_web_and_version_state() {
.await
.expect("rollback");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
for (path, mode) in [(fixture.official(), 0o555), (fixture.web(), 0o751)] {
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
mode
);
}
}
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"old-binary"
@@ -598,6 +625,8 @@ async fn rollback_should_restore_current_release_when_plugin_revision_changes_du
.expect_err("revision changed after rollback swap");
assert_eq!(error.kind(), SystemOperationErrorKind::Conflict);
#[cfg(unix)]
assert_official_directory_readonly(&fixture);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
b"new-binary"
@@ -661,6 +690,8 @@ async fn cancelled_rollback_should_restore_current_release_after_the_swap() {
.expect_err("rollback task cancelled")
.is_cancelled()
);
#[cfg(unix)]
assert_official_directory_readonly(&fixture);
assert_eq!(
fs::read(fixture.executable()).expect("binary"),
@@ -1131,10 +1162,94 @@ async fn update_should_replace_web_assets_across_filesystems() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn update_should_preserve_readonly_staged_directory_permissions() {
use std::os::unix::fs::PermissionsExt as _;
struct ReadonlyStagingPreflight(PathBuf);
#[async_trait::async_trait]
impl SystemUpdatePreflight for ReadonlyStagingPreflight {
async fn validate(
&self,
candidate: SystemUpdateCandidate,
) -> Result<Revision, SystemOperationError> {
let staging = fs::read_dir(&self.0)
.unwrap()
.next()
.expect("staging directory")
.unwrap()
.path();
let nested = staging.join("web-dist/assets");
fs::create_dir(&nested).unwrap();
fs::write(nested.join("asset.js"), "asset").unwrap();
for (path, mode) in [
(nested.join("asset.js"), 0o444),
(nested, 0o550),
(staging.join("web-dist"), 0o555),
(staging.join("official-plugins"), 0o555),
] {
fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
}
AllowingUpdatePreflight.validate(candidate).await
}
async fn confirm_revision(&self, _: Revision) -> Result<(), SystemOperationError> {
Ok(())
}
}
let server = MockServer::start().await;
let fixture = Fixture::new();
fixture
.mount_release(
&server,
TARGET_VERSION,
ArchiveKind::Safe,
ChecksumKind::Valid,
)
.await;
let config = fixture.config(&format!("{}/repos", server.uri()));
let preflight = Arc::new(ReadonlyStagingPreflight(config.update_temp_dir.clone()));
let service = ProcessSystemOperations::new(CancellationToken::new(), config);
service
.perform_update(Some(TARGET_VERSION.to_owned()), None, preflight)
.await
.expect("update accepted");
let status = wait_for_update(&service).await;
assert_eq!(
status.operation.status,
SystemOperationStatus::Succeeded,
"{status:?}"
);
assert_eq!(
fs::read(fixture.web().join("assets/asset.js")).unwrap(),
b"asset"
);
for (path, mode) in [
(fixture.official(), 0o555),
(fixture.web(), 0o555),
(fixture.web().join("assets"), 0o550),
(fixture.web().join("assets/asset.js"), 0o444),
] {
assert_eq!(
fs::metadata(path).unwrap().permissions().mode() & 0o777,
mode
);
}
}
#[tokio::test]
async fn update_should_restore_web_assets_when_binary_backup_fails() {
let server = MockServer::start().await;
let fixture = Fixture::new();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(fixture.official(), fs::Permissions::from_mode(0o555)).unwrap();
}
fixture
.mount_release(
&server,
@@ -1162,6 +1277,22 @@ async fn update_should_restore_web_assets_when_binary_backup_fails() {
);
assert!(fixture.official().join("old-plugin.tar.gz").is_file());
assert!(!fixture.official().join("new-plugin.tar.gz").exists());
#[cfg(unix)]
assert_official_directory_readonly(&fixture);
}
#[cfg(unix)]
fn assert_official_directory_readonly(fixture: &Fixture) {
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
fs::metadata(fixture.official())
.unwrap()
.permissions()
.mode()
& 0o777,
0o555
);
}
#[tokio::test]
@@ -630,6 +630,7 @@ fn candidate(id: &str, weight: u16) -> AccountCandidate {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -947,7 +947,7 @@ impl PricingSource for UnusedAdminRuntime {
impl ProxyProbe for UnusedAdminRuntime {
async fn test(
&self,
_: Option<&gateway_core::account::OutboundProxy>,
_: gateway_admin::ports::proxy::ProbeRoute<'_>,
_: bool,
) -> ProxyTestResult {
panic!("unexpected proxy probe")
@@ -279,7 +279,7 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt
`HostClient::account_http(AccountHttpRequest { account_id, credential_revision, request }, body)` 使用指定账号的当前代理配置,
经 `host.http.account` 返回同一 `HostHttpBody`。每次发送核对账号存在、启用和凭据版本,版本变化须由插件重新读取后决定下一步。
请求 headers 与正文完全由插件提供;宿主不注入账号认证、不申请推理租约,代理不可用时直接失败,不回退直连。
请求 headers 与正文完全由插件提供;宿主不注入账号认证、不申请推理租约。无显式代理时使用部署默认出口,代理不可用时直接失败,不回退直连。
这项能力与上游适配器的账号推理 HTTP 分开,适用于插件自行构造的账号网络请求
`HostClient::proxy_http(ProxyHttpRequest { proxy_id, request }, body)` 经 `host.http.proxy` 使用指定代理的当前配置,
@@ -85,7 +85,7 @@ let (message, ()) = tokio::try_join!(
```
宿主先注入已选账号认证,插件显式提供的同名头覆盖默认值。路径支持相对基址或完整 HTTP(S) URL;
声明路径用于标记发送用途,不是访问白名单。出站复用账号代理,代理失败不能回退直连,重定向不自动跟随。
声明路径用于标记发送用途,不是访问白名单。出站复用账号代理,未绑定可用代理时使用部署默认出口,代理失败不能回退直连,重定向不自动跟随。
`auxiliary` 路径的出站计入请求副作用水位,防止附件上传等操作在失败后被透明重复执行
## 事件、结算和续接
@@ -256,6 +256,15 @@ pub struct SmartSchedulingConfig {
pub latency_weight: f64,
pub reset_weight: f64,
pub queue_weight: f64,
#[serde(default)]
pub reset_window_weights: ResetWindowWeights,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ResetWindowWeights {
pub five_hour: f64,
pub weekly: f64,
pub monthly: f64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -676,6 +676,7 @@ impl RedisProviderLeaseCoordinator {
in_flight: signal.in_flight,
last_started_at: signal.last_started_at.map(Into::into),
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -126,21 +126,23 @@ async fn smart_settings_round_trip_publish_and_restore_database_defaults() {
SmartSchedulingConfig::default()
);
let mut update = settings_with_margin(3600);
update.values.smart_scheduling =
SmartSchedulingConfig::new([0.0, 2.0, 1.0, 0.5, 1.2, 2.3]).unwrap();
let expected = update.values.smart_scheduling;
let expected = serde_json::json!({
"loadWeight": 0.0, "quotaWeight": 2.0, "healthWeight": 1.0, "latencyWeight": 0.5, "resetWeight": 1.2, "queueWeight": 2.3,
"resetWindowWeights": {"fiveHour":2.0,"weekly":8.0,"monthly":0.0}
});
update.values.smart_scheduling = serde_json::from_value(expected.clone()).unwrap();
repository.update_runtime_settings(update).await.unwrap();
let reloaded = repository.load_runtime_settings().await.unwrap();
assert_eq!(reloaded.values.smart_scheduling, expected);
assert_eq!(
serde_json::to_value(reloaded.values.smart_scheduling).unwrap(),
expected
);
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
let snapshot_values = serde_json::to_value(&snapshot.settings).unwrap();
assert_eq!(
snapshot_values["smart_scheduling"],
serde_json::to_value(expected).unwrap()
);
assert_eq!(snapshot_values["smart_scheduling"], expected);
assert!(snapshot.config_revision > before.config_revision);
sqlx::query("update runtime_settings set smart_scheduling_json = default where id = 1")
.execute(&database.pool)
@@ -19,6 +19,7 @@ crc32fast = "1.5.0"
futures = "0.3.32"
flate2 = "1.1.9"
gateway-admin = { path = "../../gateway-admin" }
gateway-egress = { path = "../../gateway-egress" }
gateway-core = { path = "../../gateway-core" }
gateway-protocol = { path = "../../gateway-protocol" }
getrandom = "0.4.3"
@@ -72,15 +72,6 @@ fn normalize_codex_oauth_options(body: &mut Map<String, Value>) {
{
body.shift_remove("prompt_cache_options");
}
// 原生上限优先,Chat 别名只补缺,非法值仍交给上游校验
for alias in ["max_completion_tokens", "max_tokens"] {
if let Some(limit) = body.shift_remove(alias)
&& !limit.is_null()
&& body.get("max_output_tokens").is_none_or(Value::is_null)
{
body.insert("max_output_tokens".to_owned(), limit);
}
}
}
/// 补齐 Codex 请求缺省字段并适配已确认不兼容的请求形状,不递归清洗业务正文
@@ -121,8 +112,15 @@ pub(crate) fn normalize_codex_request_body(body: &mut Map<String, Value>) {
}
}
// Codex 拒绝显式采样温度和缓存保留时长,独立的缓存键仍须保留
for field in ["temperature", "prompt_cache_retention"] {
body.remove(field);
// Responses 上游统一忽略客户端输出上限,避免请求因上限低于模型下限被拒
// Codex 也不接受显式采样温度和缓存保留时长
for field in [
"max_output_tokens",
"max_completion_tokens",
"max_tokens",
"temperature",
"prompt_cache_retention",
] {
body.shift_remove(field);
}
}
@@ -72,7 +72,7 @@ struct CodexQuotaRefreshAttempt {
wall_at: SystemTime,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
struct CodexQuotaSchedulingEntry {
version: u64,
revision: CredentialRevision,
@@ -211,7 +211,7 @@ impl CodexQuotaSchedulingProjection {
.filter(|entry| {
entry.revision == account.revision() && Instant::now() < entry.expires_at
})
.and_then(|entry| entry.signals)
.and_then(|entry| entry.signals.clone())
}
pub(super) fn reserve_periodic_refreshes(
@@ -8,8 +8,8 @@ use std::time::{Duration, SystemTime};
use chrono::{DateTime, Utc};
use gateway_core::account::{
AccountQuotaSignals, CredentialRevision, ProviderAccountId, QuotaEvidence, QuotaObservation,
QuotaState,
AccountQuotaResetWindow, AccountQuotaSignals, CredentialRevision, ProviderAccountId,
QuotaEvidence, QuotaObservation, QuotaResetPeriod, QuotaState,
};
use gateway_protocol::openai::events::{CreditsSnapshot, parse_credits_from_object};
use serde_json::{Map, Value};
@@ -371,6 +371,7 @@ pub(crate) fn quota_scheduling_signals(
let now = SystemTime::now();
let mut remaining_rank: Option<u64> = None;
let mut reset_at: Option<SystemTime> = None;
let mut reset_windows = Vec::new();
for window in &snapshot.windows {
if quota_window_ttl(window, snapshot.observed_at(), now).is_none() {
continue;
@@ -385,10 +386,22 @@ pub(crate) fn quota_scheduling_signals(
}
if let Some(reset) = window_reset_at.filter(|reset| *reset > now) {
reset_at = Some(reset_at.map_or(reset, |current| current.min(reset)));
if window.is_account_wide() {
let period = match window.kind() {
CodexQuotaWindowKind::ShortTerm => QuotaResetPeriod::FiveHour,
CodexQuotaWindowKind::Weekly => QuotaResetPeriod::Weekly,
CodexQuotaWindowKind::Monthly => QuotaResetPeriod::Monthly,
CodexQuotaWindowKind::Other => continue,
};
reset_windows.push(AccountQuotaResetWindow {
period,
reset_at: reset,
});
}
}
}
if remaining_rank.is_some() || reset_at.is_some() {
Some(AccountQuotaSignals::new(reset_at, remaining_rank))
Some(AccountQuotaSignals::new(reset_at, remaining_rank).with_reset_windows(reset_windows))
} else {
None
}
@@ -636,9 +649,7 @@ fn parse_rate_limit_windows(
.transpose()?;
let limit_reached = optional_bool(window, "limit_reached")?.unwrap_or(false);
let kind = quota_window_kind(window_seconds);
// primary/secondary 是协议槽位,不代表固定周期
// 窗口身份使用上游
// limit_id 和实际时长,5h/周/月分类只参与展示
// 槽位不代表周期,窗口键保留上游额度桶和实际时长;周期分类供展示及智能调度使用
let key = match window_seconds {
Some(seconds) => format!("{source}:{seconds}s"),
None => format!("{source}:{name}"),
@@ -1054,6 +1054,7 @@ impl CodexCredentialSelector {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -401,9 +401,8 @@ impl OpenAiTokenClient {
let Some(proxy) = proxy else {
return Ok(self.clone());
};
let builder = Client::builder()
.no_proxy()
.proxy(reqwest::Proxy::all(proxy.expose_url()).map_err(|_| TokenClientBuildError)?)
let builder = gateway_egress::http_client(Some(proxy))
.map_err(|_| TokenClientBuildError)?
.redirect(Policy::none())
.connect_timeout(TOKEN_CONNECT_TIMEOUT)
.timeout(TOKEN_REQUEST_TIMEOUT);
@@ -498,8 +497,8 @@ pub fn openai_token_client(
profile: CodexWireProfileState,
) -> Result<OpenAiTokenClient, TokenClientBuildError> {
ensure_rustls_provider();
let builder = Client::builder()
.no_proxy()
let builder = gateway_egress::http_client(None)
.map_err(|_| TokenClientBuildError)?
.redirect(Policy::none())
.connect_timeout(TOKEN_CONNECT_TIMEOUT)
.timeout(TOKEN_REQUEST_TIMEOUT);
@@ -4,6 +4,7 @@ use gateway_core::metering::{CalculatedCost, Usage};
use super::*;
use crate::credential::CodexSelectionModel;
use crate::transport::CodexTransportDecision;
use gateway_core::operation::RawJsonPayload;
impl CodexProvider {
@@ -925,6 +926,8 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
return;
}
if let Some(capture) = session_capture.as_mut() {
capture.oversized_http = response.transport_metrics.decision
== Some(CodexTransportDecision::Http2WebSocketMessageTooLarge);
capture.continuation_scope = Some(if capture.response_store {
OpenAiContinuationScope::Persisted
} else if response.transport == CodexBackendTransport::WebSocket
@@ -918,6 +918,13 @@ pub(super) fn map_client_error(
websocket_error_kind(error),
websocket_send_state(error),
));
if matches!(
error.classified(),
CodexWebSocketExchangeError::MessageTooLarge
) {
// 可回退请求已在 transport 内转为 HTTP,剩余预热请求不能重复发送
failure.error = failure.error.with_retry_prohibited();
}
if let CodexWebSocketExchangeError::ConnectionLimitReached(upstream) =
error.classified()
{
@@ -1180,6 +1187,7 @@ fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnost
CodexWebSocketExchangeError::InvalidRequest(_) => ("prepare", "invalid_request"),
CodexWebSocketExchangeError::SendTimeout { .. } => ("send", "send_timeout"),
CodexWebSocketExchangeError::SendNotStarted => ("send", "send_not_started"),
CodexWebSocketExchangeError::MessageTooLarge => ("prepare", "websocket_message_too_large"),
CodexWebSocketExchangeError::ReceiveIdleTimeout { .. } => {
("receive", "receive_idle_timeout")
}
@@ -1276,6 +1284,10 @@ fn websocket_diagnostic_message(error: &CodexWebSocketExchangeError) -> Provider
CodexWebSocketExchangeError::SendNotStarted => {
"OpenAI WebSocket connection closed before payload send started".to_owned()
}
CodexWebSocketExchangeError::MessageTooLarge => {
"OpenAI WebSocket request exceeds the advertised message size limit before send"
.to_owned()
}
CodexWebSocketExchangeError::SendTimeout { timeout } => {
format!("OpenAI WebSocket payload send timed out after {timeout:?}")
}
@@ -1345,6 +1357,16 @@ fn websocket_raw_error(error: &CodexWebSocketExchangeError) -> Option<RawUpstrea
pub(super) fn websocket_client_visible_error(
error: &CodexWebSocketExchangeError,
) -> Option<ClientVisibleUpstreamError> {
if matches!(
error.classified(),
CodexWebSocketExchangeError::MessageTooLarge
) {
return Some(ClientVisibleUpstreamError::new(
"The WebSocket request exceeds the server's message size limit.".to_owned(),
Some("websocket_message_too_large".to_owned()),
Some("invalid_request_error".to_owned()),
));
}
if let CodexWebSocketExchangeError::ConnectionLimitReached(failure) = error.classified() {
return Some(ClientVisibleUpstreamError::new(
failure.message.clone(),
@@ -1648,6 +1670,7 @@ pub(super) fn websocket_send_state(error: &CodexWebSocketExchangeError) -> Upstr
| CodexWebSocketExchangeError::OriginHalfOpenBusy
| CodexWebSocketExchangeError::SharedConnectFailed
| CodexWebSocketExchangeError::SendNotStarted
| CodexWebSocketExchangeError::MessageTooLarge
| CodexWebSocketExchangeError::ContinuationUnavailable { .. } => UpstreamSendState::NotSent,
CodexWebSocketExchangeError::Upstream(_)
| CodexWebSocketExchangeError::ConnectionLimitReached(_)
@@ -1670,6 +1693,7 @@ pub(super) fn websocket_send_state(error: &CodexWebSocketExchangeError) -> Upstr
pub(super) fn websocket_error_kind(error: &CodexWebSocketExchangeError) -> ProviderErrorKind {
match error.classified() {
CodexWebSocketExchangeError::MessageTooLarge => ProviderErrorKind::MessageTooBig,
CodexWebSocketExchangeError::InvalidRequest(_)
| CodexWebSocketExchangeError::InvalidSse(_)
| CodexWebSocketExchangeError::UnexpectedBinaryEvent => ProviderErrorKind::Protocol,
@@ -67,7 +67,7 @@ use crate::credential::{
derive_live_session_affinity, derive_previous_response_id_hash, derive_turn_alias,
};
use crate::request_identity::{account_session_with_headers, turn_id_with_headers};
use crate::session_transport::{CodexSessionTransportRecovery, WS_PAYLOAD_LIMIT};
use crate::session_transport::CodexSessionTransportRecovery;
use crate::transport::canonical::{
CodexCanonicalDecoder, CodexCanonicalError, CodexCanonicalOutcome,
};
@@ -84,7 +84,6 @@ use crate::transport::protocol::responses::{
CodexResponsesRequest, PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
PreviousResponseScope, ResponseEventSignals, TransportRequirement, transport_requirement,
};
use crate::transport::protocol::websocket::websocket_response_create_payload_len;
use crate::transport::request::{
CodexRequestEncodeError, RequestAccountScope, align_structured_location_fields,
clear_request_turn_state, encode_generate_request, scope_request_to_account,
@@ -1069,54 +1068,13 @@ impl CodexProvider {
&& session_transport_key
.as_ref()
.is_some_and(|key| self.session_transport_recovery.uses_http(key));
let mut transport = if requirement.requires_websocket() {
let transport = if requirement.requires_websocket() {
CodexProviderTransport::PreferWebSocket
} else if context.transport() == AttemptTransport::Fallback || session_http_fallback {
CodexProviderTransport::HttpOnly
} else {
requested_transport
};
let mut oversized_http = false;
if codex_oauth
&& transport == CodexProviderTransport::PreferWebSocket
&& (matches!(
requirement,
TransportRequirement::NewChain | TransportRequirement::WebSocketNewChain
) || (requirement == TransportRequirement::ExactWebSocketContinuation
&& upstream_request
.downstream_websocket_connection_id
.is_some()))
{
let payload_bytes =
websocket_response_create_payload_len(&upstream_request).map_err(|_| {
provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent)
})?;
if requirement == TransportRequirement::ExactWebSocketContinuation {
if payload_bytes >= WS_PAYLOAD_LIMIT {
tracing::info!(
request_id = context.request_id().as_str(),
payload_bytes,
threshold_bytes = WS_PAYLOAD_LIMIT,
continuation_recovery_action = "client_replay_required",
"OpenAI oversized WebSocket continuation requires full client input before send"
);
return Err(continuation_replay_required_error(
"transport_payload_too_large",
));
}
} else if self
.session_transport_recovery
.http_for_full_payload(session_transport_key.as_ref(), payload_bytes)
{
transport = CodexProviderTransport::HttpOnly;
oversized_http = true;
tracing::info!(
request_id = context.request_id().as_str(),
payload_bytes,
"OpenAI full request selected HTTP by WebSocket payload size"
);
}
}
if transport == CodexProviderTransport::PreferWebSocket
&& middleware_headers.iter().any(|header| {
match HeaderValue::from_bytes(header.value()) {
@@ -1173,7 +1131,7 @@ impl CodexProvider {
)
.reasoning_effort_override,
response_store,
oversized_http,
oversized_http: false,
continuation_scope: None,
});
let allows_account_state_mutation = lease.allows_account_state_mutation();
@@ -1,4 +1,4 @@
//! 按客户端线程维护 WS 失败预算及大包 HTTP 回退区间,空闲过期后释放状态
//! 按客户端线程维护 WS 失败预算,空闲过期后释放状态
use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard};
@@ -7,11 +7,6 @@ use std::time::Duration;
use gateway_core::provider_ports::ProviderSessionAffinityKey;
use tokio::time::Instant;
// 在实测的 16 MiB 上游 WS 边界前留半 MiB,再用 2 MiB 回差避免频繁切换
const MIB: usize = 1024 * 1024;
pub(crate) const WS_PAYLOAD_LIMIT: usize = 15 * MIB + MIB / 2;
const WS_PAYLOAD_RESUME: usize = WS_PAYLOAD_LIMIT - 2 * MIB;
const SESSION_IDLE_RETENTION: Duration = Duration::from_secs(8 * 60 * 60);
const MAX_SESSION_TRANSPORTS: usize = 16_384;
@@ -22,7 +17,6 @@ pub(crate) struct CodexSessionTransportRecovery {
struct SessionTransport {
state: TransportState,
oversized: bool,
last_used: Instant,
}
@@ -70,31 +64,11 @@ impl CodexSessionTransportRecovery {
}
pub(crate) fn websocket_succeeded(&self, key: &ProviderSessionAffinityKey) {
// 预热或增量成功不能证明下一份完整请求能放进 WS
self.update(key, |session| {
session.state = TransportState::WebSocket { failures: 0 }
});
}
/// 只用完整新链的实际帧长度更新大小偏好,重连沿用同一线程的判断
pub(crate) fn http_for_full_payload(
&self,
key: Option<&ProviderSessionAffinityKey>,
bytes: usize,
) -> bool {
let Some(key) = key else {
return bytes >= WS_PAYLOAD_LIMIT;
};
self.update(key, |session| {
if bytes >= WS_PAYLOAD_LIMIT {
session.oversized = true;
} else if bytes < WS_PAYLOAD_RESUME {
session.oversized = false;
}
session.oversized
})
}
fn update<T>(
&self,
key: &ProviderSessionAffinityKey,
@@ -107,12 +81,10 @@ impl CodexSessionTransportRecovery {
.filter(|s| now.saturating_duration_since(s.last_used) < SESSION_IDLE_RETENTION)
.unwrap_or(SessionTransport {
state: TransportState::WebSocket { failures: 0 },
oversized: false,
last_used: now,
});
let result = update(&mut session);
if !session.oversized && matches!(session.state, TransportState::WebSocket { failures: 0 })
{
if matches!(session.state, TransportState::WebSocket { failures: 0 }) {
return result;
}
session.last_used = now;
@@ -82,17 +82,11 @@ pub(super) fn build_account_http_client_with_timeout(
}
// 连接池与 TCP、HTTP/2 保活沿用官方 Core 的 reqwest 默认值
let mut builder = Client::builder()
.no_proxy()
let builder = gateway_egress::http_client(proxy)
.map_err(|_| CustomCaError::ProxyConfiguration)?
.redirect(reqwest::redirect::Policy::none())
.connect_timeout(timeout)
.connector_layer(super::connection::ConnectionLayer);
if let Some(proxy) = proxy {
builder = builder.proxy(
reqwest::Proxy::all(proxy.expose_url())
.map_err(|_| CustomCaError::ProxyConfiguration)?,
);
}
let client = build_reqwest_client_with_custom_ca(builder)?;
let mut clients = cache
.lock()
@@ -110,7 +104,7 @@ fn egress_key(account_id: &str, proxy: Option<&gateway_core::account::OutboundPr
hash.update([0]);
hash.update(
proxy
.map_or("direct", |proxy| proxy.expose_url())
.map_or("deployment", |proxy| proxy.expose_url())
.as_bytes(),
);
hex::encode(hash.finalize())
@@ -590,6 +584,7 @@ pub enum CodexTransportDecision {
ExactWebSocket,
RequiredWebSocket,
Http2WebSocketBudgetExhausted,
Http2WebSocketMessageTooLarge,
Http2LocalConnectionCapacity,
Http2BreakerOpen,
Http2PoolUnavailable,
@@ -604,6 +599,7 @@ impl CodexTransportDecision {
Self::ExactWebSocket => "ws_exact_required",
Self::RequiredWebSocket => "ws_required",
Self::Http2WebSocketBudgetExhausted => "http2_ws_budget_exhausted",
Self::Http2WebSocketMessageTooLarge => "http2_ws_message_too_large",
Self::Http2LocalConnectionCapacity => "http2_local_connection_capacity",
Self::Http2BreakerOpen => "http2_breaker_open",
Self::Http2PoolUnavailable => "http2_pool_unavailable",
@@ -708,7 +704,7 @@ pub struct CodexBackendClient {
pub(super) timezone: gateway_core::time::DeploymentTimeZone,
pub(super) connection_budget: Option<gateway_core::engine::connection::ConnectionBudget>,
pub(super) client: Client,
pub(super) direct_client: Client,
pub(super) default_client: Client,
pub(super) base_url: String,
pub(super) official_base_url: String,
pub(super) protocol: OpenAiUpstreamProtocol,
@@ -746,7 +742,7 @@ impl CodexBackendClient {
privacy: None,
timezone: Default::default(),
connection_budget: None,
direct_client: client.clone(),
default_client: client.clone(),
client,
websocket_origin_key: websocket_origin_key(&base_url),
outbound_proxy: None,
@@ -908,7 +904,7 @@ impl CodexBackendClient {
client.client = if account.outbound_proxy().is_some() {
build_account_http_client(account.id().as_str(), account.outbound_proxy())?
} else {
self.direct_client.clone()
self.default_client.clone()
};
Ok(client)
}
@@ -33,8 +33,8 @@ use crate::transport::{
response_meta,
websocket::{
CodexWebSocketConnection, CodexWebSocketExchangeError, CodexWebSocketPoolKey,
DEFAULT_STREAM_IDLE_TIMEOUT, WEBSOCKET_FAST_PATH_BUDGET, WebSocketFastPath,
execute_prepared_response_create_request_stream, post_send_ambiguous,
DEFAULT_STREAM_IDLE_TIMEOUT, PreviousResponseUnavailableReason, WEBSOCKET_FAST_PATH_BUDGET,
WebSocketFastPath, execute_prepared_response_create_request_stream, post_send_ambiguous,
prepare_response_create_request_with_pool, websocket_audit_dir,
write_websocket_audit_artifact_from_env,
},
@@ -459,6 +459,51 @@ impl CodexBackendClient {
}
Err(error) => return Err(websocket_exchange_error_to_client_error(error)),
};
// 按最终出站帧的 UTF-8 字节数判断,握手上限只属于当前物理连接
if let Some(limit) = prepared.max_message_bytes()
&& websocket_create.payload_text().len() > limit
{
let metrics = CodexTransportMetrics {
decision: Some(CodexTransportDecision::Http2WebSocketMessageTooLarge),
ws_connect_ms: prepared.connect_elapsed().map(elapsed_duration_millis),
transport_decision_wait_ms: Some(elapsed_duration_millis(
prepared.decision_wait_elapsed(),
)),
..CodexTransportMetrics::default()
};
prepared.discard().await;
let error = match requirement {
TransportRequirement::ExactWebSocketContinuation => {
Some(CodexWebSocketExchangeError::ContinuationUnavailable {
reason: PreviousResponseUnavailableReason::PayloadTooLarge,
})
}
TransportRequirement::ExplicitWebSocketWarmup => {
Some(CodexWebSocketExchangeError::MessageTooLarge)
}
TransportRequirement::HttpRequired
| TransportRequirement::WebSocketNewChain
| TransportRequirement::PersistedContinuation
| TransportRequirement::ExternalUnknown
| TransportRequirement::NewChain => None,
};
if let Some(error) = error {
return Err(websocket_exchange_error_to_client_error(error));
}
context.trace.cloned().unwrap_or_default().record(
"transport.fallback",
serde_json::json!({
"to": "http_sse", "reason": "websocket_message_too_large",
"requirement": requirement.as_str(), "limitBytes": limit,
"payloadBytes": websocket_create.payload_text().len(),
}),
);
return Ok(PreparedResponseTransport {
requirement,
route: PreparedResponseRoute::Http,
metrics,
});
}
let decision = websocket_success_decision(requirement, &prepared);
context.trace.cloned().unwrap_or_default().record(
"transport.prepared",
@@ -80,9 +80,9 @@ impl CliReleaseService {
state: CodexWireProfileState,
cache: Arc<dyn ProviderArtifactProfileCachePort>,
) -> Result<Self, CliReleaseError> {
let client = Client::builder()
let client = gateway_egress::http_client(None)
.map_err(|_| CliReleaseError::Fetch)?
.https_only(true)
.no_proxy()
.redirect(Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
@@ -555,13 +555,13 @@ pub struct OfficialCodexDesktopReleaseTransport {
}
impl OfficialCodexDesktopReleaseTransport {
/// 构造禁用环境代理和 redirect 的官方 HTTPS transport
/// 构造使用部署出口且禁用 redirect 的官方 HTTPS transport
pub fn new() -> Result<Self, CodexDesktopReleaseError> {
let endpoint = Url::parse(CODEX_DESKTOP_APPCAST_URL)
.map_err(|_| CodexDesktopReleaseError::InvalidEndpoint)?;
let client = Client::builder()
let client = gateway_egress::http_client(None)
.map_err(|_| CodexDesktopReleaseError::ClientInitialization)?
.https_only(true)
.no_proxy()
.redirect(Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(APPCAST_TIMEOUT)
@@ -92,9 +92,9 @@ pub struct OfficialDesktopArtifactTransport {
}
impl OfficialDesktopArtifactTransport {
pub fn new() -> io::Result<Self> {
let client = Client::builder()
let client = gateway_egress::http_client(None)
.map_err(|_| invalid())?
.https_only(true)
.no_proxy()
.redirect(Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
@@ -233,35 +233,6 @@ pub fn websocket_response_create_payload_text(
})
}
pub(crate) fn websocket_response_create_payload_len(
request: &CodexResponsesRequest,
) -> Result<usize, serde_json::Error> {
struct ByteCount(usize);
impl std::io::Write for ByteCount {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0 = self
.0
.checked_add(bytes.len())
.ok_or_else(|| std::io::Error::other("WebSocket payload size overflow"))?;
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
let mut count = ByteCount(0);
serde_json::to_writer(
&mut count,
&ResponseCreateFrame {
body: request.body(),
},
)?;
Ok(count.0)
}
fn websocket_payload_keys(request: &CodexResponsesRequest) -> Vec<String> {
let mut keys = Vec::with_capacity(request.body().len() + 1);
keys.push("type".to_string());
@@ -92,6 +92,18 @@ impl PoolBinding {
}
impl PreparedWebSocket {
pub(crate) fn max_message_bytes(&self) -> Option<usize> {
self.connection.metadata.max_message_bytes
}
/// 本地预检拒绝后先释放连接和池租约,再开始其他传输
pub(crate) async fn discard(self) {
let (lease, _, _) = self.binding.into_parts();
let websocket = self.connection.websocket;
let observation = websocket.observation().with_exit_reason("request_not_sent");
discard_connection(websocket, lease, observation).await;
}
pub(crate) fn pool_decision(&self) -> Option<WebSocketPoolDecision> {
self.binding.decision()
}
@@ -645,7 +657,7 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
"reused": reused,
}),
);
discard_after_send(websocket, lease, observation.clone()).await;
discard_connection(websocket, lease, observation.clone()).await;
let error = if not_started {
if request.continuation().permits_fresh_connection() {
error
@@ -680,7 +692,7 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
Ok(exchange)
}
async fn discard_after_send(
async fn discard_connection(
websocket: PumpedWebSocket,
lease: Option<WebSocketPoolLease>,
observation: super::pump::WebSocketConnectionObservation,
@@ -53,6 +53,9 @@ pub enum CodexWebSocketExchangeError {
/// 连接已关闭或 pump 已退出,业务帧尚未交给底层发送
#[error("websocket connection closed before payload send started")]
SendNotStarted,
/// 最终请求帧超过当前握手声明的上限,尚未交给底层发送
#[error("websocket request exceeds the advertised message size limit")]
MessageTooLarge,
/// payload 已可能送达上游,禁止自动重放到其他 transport 或账号
#[error("websocket failed after payload send; replay outcome is ambiguous: {message}")]
PostSendAmbiguous {
@@ -1,14 +1,12 @@
//! Responses WebSocket endpoint、opening handshake 与首帧发送
use std::time::Duration;
use std::{error::Error as _, io, time::Duration};
use bytes::Bytes;
use gateway_core::error::ProxyConnectionHint;
use gateway_protocol::openai::events;
use tokio::{io::BufWriter, time::timeout};
use tokio_tungstenite::{
Connector, MaybeTlsStream, client_async_tls_with_config, connect_async_tls_with_config,
};
use tokio_tungstenite::{Connector, MaybeTlsStream, client_async_tls_with_config};
use tungstenite::{
self, Message,
extensions::{ExtensionsConfig, compression::deflate::DeflateConfig},
@@ -184,6 +182,11 @@ pub(super) fn websocket_connection_metadata(
connection: &CodexWebSocketConnection,
) -> CodexWebSocketConnectionMetadata {
CodexWebSocketConnectionMetadata {
max_message_bytes: response
.headers()
.get("x-codex-websocket-max-message-bytes")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok()),
routing_affinity: crate::transport::routing_affinity::ChatGptRoutingAffinity::from_response(
&connection.endpoint,
response.headers(),
@@ -233,22 +236,6 @@ async fn connect_websocket(
crate::transport::connection::acquire().await
}
.map_err(tungstenite::Error::Io)?;
// 保留原生直连握手,显式出口不继承全局代理
if connection.outbound_proxy.is_none()
&& matches!(
tungstenite::proxy::ProxyConfig::from_env(request.uri()),
Ok(None)
)
{
let (websocket, response) = connect_async_tls_with_config(
request,
Some(websocket_config(connection)),
false,
connector,
)
.await?;
return Ok((Box::new(websocket) as RawWsStream, response));
}
let stream = dial_account(connection, &mut proxy_hint).await?;
match stream {
MaybeTlsStream::Plain(tcp) => {
@@ -305,10 +292,14 @@ async fn dial_account(
_ => host.to_owned(),
};
let port = endpoint.port_or_known_default().ok_or_else(invalid)?;
let Some(proxy) = connection.outbound_proxy.as_ref() else {
return Ok(MaybeTlsStream::Plain(connect_tcp(host, port).await?));
};
*proxy_hint = Some(ProxyConnectionHint::InvalidConfiguration);
let Some(proxy) =
gateway_egress::for_destination(connection.outbound_proxy.as_ref(), &endpoint)
.map_err(|_| invalid())?
else {
*proxy_hint = None;
return Ok(MaybeTlsStream::Plain(connect_tcp(&dns_host, port).await?));
};
let mut proxy_url = url::Url::parse(proxy.expose_url()).map_err(|_| invalid())?;
let tls_proxy = proxy_url.scheme() == "https";
let proxy_port = proxy_url.port_or_known_default().ok_or_else(invalid)?;
@@ -363,7 +354,7 @@ async fn dial_account(
Ok(stream.into_inner())
}
async fn connect_tcp(host: &str, port: u16) -> Result<tokio::net::TcpStream, tungstenite::Error> {
async fn connect_tcp(host: &str, port: u16) -> io::Result<tokio::net::TcpStream> {
use hyper_util::client::legacy::connect::HttpConnector;
use tower_service::Service;
@@ -374,14 +365,20 @@ async fn connect_tcp(host: &str, port: u16) -> Result<tokio::net::TcpStream, tun
);
let uri = format!("http://{authority}")
.parse::<hyper::Uri>()
.map_err(|_| tungstenite::Error::Io(std::io::Error::other("invalid egress endpoint")))?;
.map_err(|_| io::Error::other("invalid egress endpoint"))?;
let mut connector = HttpConnector::new();
connector
.call(uri)
.await
.map(|stream| stream.into_inner())
.map_err(|_| {
tungstenite::Error::Io(std::io::Error::other("account WebSocket connection failed"))
.map_err(|error| {
// 保留系统错误码或 I/O 类型,不把 Hyper 记录的目标地址带入诊断
let Some(cause) = error.source().and_then(|e| e.downcast_ref::<io::Error>()) else {
return io::Error::other("account WebSocket connection failed");
};
cause
.raw_os_error()
.map_or_else(|| cause.kind().into(), io::Error::from_raw_os_error)
})
}
@@ -102,6 +102,7 @@ pub enum PreviousResponseUnavailableReason {
LatestResponseMismatch,
ReusedConnectionLost,
UpstreamRejected,
PayloadTooLarge,
}
impl PreviousResponseUnavailableReason {
@@ -113,6 +114,7 @@ impl PreviousResponseUnavailableReason {
Self::ConnectionBusy => "connection_busy",
Self::LatestResponseMismatch => "latest_response_mismatch",
Self::ReusedConnectionLost => "reused_connection_lost",
Self::PayloadTooLarge => "transport_payload_too_large",
Self::UpstreamRejected => "upstream_rejected",
}
}
@@ -194,6 +194,7 @@ impl WebSocketPoolState {
#[derive(Clone)]
pub(crate) struct CodexWebSocketConnectionMetadata {
pub(crate) max_message_bytes: Option<usize>,
pub(crate) routing_affinity: Option<crate::transport::routing_affinity::ChatGptRoutingAffinity>,
pub(crate) turn_state: Option<String>,
pub(crate) set_cookie_headers: Vec<String>,
@@ -69,7 +69,6 @@ fn encoder_should_adapt_pi_responses_parameters_without_losing_codex_fields() {
"stream": true,
"store": false,
"prompt_cache_key": "client-session",
"max_output_tokens": 512,
"reasoning": {"effort": "high", "summary": "auto"},
"include": ["reasoning.encrypted_content"]
})
@@ -126,7 +125,6 @@ fn encoder_should_default_missing_store_without_changing_unknown_or_nested_field
"input": [{"role": "user", "content": [{
"type": "input_text", "text": "temperature and max_output_tokens are tool parameter names"
}]}],
"max_tokens": 256,
"future_options": {"temperature": 0.7},
"client_metadata": {"prompt_cache_retention": "business-value", "store": true},
"tools": [{
@@ -16,6 +16,7 @@ fn busy_signal(in_flight: u32) -> AccountRuntimeSignals {
in_flight,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -52,6 +52,7 @@ fn signal(in_flight: u32) -> AccountRuntimeSignals {
in_flight,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -86,6 +86,7 @@ async fn old_weekly_quota_should_outweigh_one_in_flight_title_request() {
in_flight: u32::from(account.id().as_str() == "acct_74"),
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -280,3 +281,88 @@ async fn reset_window_should_not_clear_confirmed_exhaustion() {
QuotaAccessState::Exhausted
);
}
#[tokio::test]
async fn weekly_window_prefers_the_first_weekly_reset_independently_of_short_resets() {
let store = Arc::new(MemoryAccountStore::default());
let now = UNIX_EPOCH + Duration::from_secs(timestamp(SystemTime::now()));
let mut accounts = Vec::new();
// 三个账号的短窗口与周窗口顺序相反,确保选择的是配置的窗口而非最早时间
for (id, hour, day) in [("acct_one", 1, 2), ("acct_two", 2, 3), ("acct_three", 3, 1)] {
accounts.push(persist_snapshot(&store, id, now, json!({
"primary_window": {"used_percent":10,"limit_window_seconds":18000,"reset_at":timestamp(now) + hour * 3600u64},
"secondary_window": {"used_percent":10,"limit_window_seconds":604800,"reset_at":timestamp(now) + day * 86400u64}
})).await);
}
let service = quota_service(&store);
service.prepare_scheduling(&accounts).await;
let candidates = accounts
.into_iter()
.map(|account| AccountCandidate {
inherited_concurrency: None,
signals: AccountRuntimeSignals {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
first_output_latency_ms: None,
}
.with_provider_quota(service.scheduling_signals(&account)),
account,
})
.collect::<Vec<_>>();
let mut config = json!({"loadWeight":0,"quotaWeight":0,"healthWeight":0,"latencyWeight":0,"resetWeight":1,"queueWeight":0});
let policy = |strategy, config: &Value| {
AccountSelectionPolicy::new(strategy, NonZeroU32::new(3).unwrap())
.with_smart_scheduling(serde_json::from_value(config.clone()).unwrap())
};
let mut context = AccountSelectionContext {
policy: policy(RotationStrategy::Smart, &config),
now,
excluded_accounts: Default::default(),
busy_accounts: Default::default(),
preferred_account: None,
preferred_account_overrides_weight: false,
round_robin_cursor: 0,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: None,
reserved_concurrency: 0,
};
let selected = |pool: &[AccountCandidate], context: &AccountSelectionContext| {
AccountSelector
.select(pool, context)
.unwrap()
.candidate()
.account
.id()
.as_str()
.to_owned()
};
config["resetWindowWeights"] = json!({"fiveHour":1,"weekly":0,"monthly":0});
context.policy = policy(RotationStrategy::Smart, &config);
assert_eq!(selected(&candidates, &context), "acct_one");
config["resetWindowWeights"] = json!({"fiveHour":0,"weekly":1,"monthly":0});
context.policy = policy(RotationStrategy::Smart, &config);
assert_eq!(selected(&candidates, &context), "acct_three");
let trace = gateway_core::diagnostics::TraceContext::new("req_weekly");
trace.account_selection(&candidates, &context, None);
let snapshot = trace.snapshot().unwrap();
let data = &snapshot["events"][0]["data"];
for (candidate, expected) in
data["candidates"]
.as_array()
.unwrap()
.iter()
.zip([1.0 / 3.0, 0.25, 0.5])
{
assert!((candidate["smartScore"].as_f64().unwrap() - expected).abs() < 1e-10);
}
assert!(1.0 / 3.0 - 0.25 > data["smartScoreTolerance"].as_f64().unwrap());
context.round_robin_cursor = 1;
assert_eq!(selected(&candidates[..2], &context), "acct_one");
context.policy = policy(RotationStrategy::QuotaResetPriority, &config);
assert_eq!(selected(&candidates, &context), "acct_one");
}
@@ -194,6 +194,7 @@ async fn guardian_and_normal_requests_use_independent_capacity_and_observations(
in_flight,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -326,6 +327,7 @@ async fn a_full_capacity_pool_does_not_block_the_other_pools_queue() {
in_flight: if guardian_waits { 1 } else { 2 },
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -294,6 +294,7 @@ async fn messages_and_responses_translate_with_target_header_boundaries() {
} else {
let body: Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(body["reasoning"]["effort"], "xhigh");
assert!(body.get("max_output_tokens").is_none());
for name in [
"anthropic-version",
"anthropic-beta",
@@ -3879,6 +3879,7 @@ async fn capacity_selection_error_preserves_classification_and_retry_after() {
in_flight: u32::MAX,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -4389,7 +4390,7 @@ async fn connection_failure_preserves_io_cause_without_claiming_payload_was_sent
if let Some(rejection) = rejection {
rejection.await.unwrap();
}
if proxy_hint.is_none() {
if matches!(proxy_hint, None | Some(ProxyConnectionHint::Unreachable)) {
assert_eq!(diagnostic.code(), Some("connection_refused"));
}
assert_eq!(diagnostic.stage(), Some("connect"));
@@ -5236,419 +5237,255 @@ async fn websocket_upgrade_required_immediately_enables_session_http_fallback()
}
#[tokio::test]
async fn oversized_http_new_chain_uses_http_without_disabling_session_websocket() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
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(1)
.mount(&server)
.await;
let input = json!([
{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": "inspect the screenshot"},
{"type": "input_image", "image_url": format!("data:image/jpeg;base64,{}", "A".repeat(17 * 1024 * 1024))}
]},
{"type": "function_call", "call_id": "call_large", "name": "inspect", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_large", "output": "done"}
]);
let tools = json!([{"type": "function", "name": "inspect", "parameters": {"type": "object"}}]);
let body = Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), input.clone()),
("tools".to_owned(), tools.clone()),
("session_id".to_owned(), json!("oversized-http-session")),
("stream".to_owned(), json!(true)),
("store".to_owned(), json!(false)),
]);
let provider = provider_with_base_url(&store, server.uri());
let mut stream = Arc::clone(&provider)
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body).unwrap(),
)),
),
context("req_oversized_http", CancellationToken::new()),
)
.await
.expect("prepare oversized HTTP request");
assert_eq!(stream.metadata().transport().as_str(), "http_sse");
let mut completed = false;
while let Some(event) = stream.next().await {
let event = event.expect("oversized HTTP response");
completed |= event
.wire_event()
.is_some_and(|wire| wire.event_type() == Some("response.completed"));
}
assert!(completed);
drop(stream);
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method.as_str(), "POST");
let sent = captured_request_body(&requests[0]);
assert_eq!(sent["input"], input);
assert_eq!(sent["tools"], tools);
assert!(sent.get("previous_response_id").is_none());
let next = provider
.execute(
planned_request(
"openai",
Operation::Generate(generate_with_session_context(
"oversized-http-session",
None,
None,
)),
),
context("req_small_after_oversized_http", CancellationToken::new()),
)
.await
.expect("prepare small request in the same session");
assert_eq!(next.metadata().transport().as_str(), "websocket");
}
#[tokio::test]
async fn oversized_http_threshold_counts_utf8_and_json_escaping() {
const THRESHOLD: usize = 15 * 1024 * 1024 + 512 * 1024;
const RESUME: usize = THRESHOLD - 2 * 1024 * 1024;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
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(1)
.mount(&server)
.await;
let seed_text = "中文\n\"\\";
let mut body = Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
(
"input".to_owned(),
json!([{"type": "message", "role": "user", "content": [{"type": "input_text", "text": seed_text}]}]),
),
("session_id".to_owned(), json!("oversized-boundary-session")),
("stream".to_owned(), json!(true)),
("store".to_owned(), json!(false)),
]);
let provider = provider_with_base_url(&store, server.uri());
let mut seed = Arc::clone(&provider)
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body.clone())
.unwrap()
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])),
)),
),
context("req_size_baseline", CancellationToken::new()),
)
.await
.unwrap();
while let Some(event) = seed.next().await {
event.expect("baseline HTTP response");
}
drop(seed);
let requests = server.received_requests().await.unwrap();
let mut frame = captured_request_body(&requests[0]);
frame["type"] = json!("response.create");
// 发送前阈值按本地请求副本计量,账号域映射在实际出站时执行
frame["session_id"] = body["session_id"].clone();
let baseline_len = serde_json::to_vec(&frame).unwrap().len();
for (payload_len, expected) in [
(THRESHOLD - 1, "websocket"),
(THRESHOLD, "http_sse"),
(THRESHOLD - 1, "http_sse"),
(RESUME, "http_sse"),
(RESUME - 1, "websocket"),
(THRESHOLD - 1, "websocket"),
] {
let text = format!("{seed_text}{}", "x".repeat(payload_len - baseline_len));
body.get_mut("input").unwrap()[0]["content"][0]["text"] = json!(text);
let stream = Arc::clone(&provider)
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body.clone()).unwrap(),
)),
),
context(
&format!("req_boundary_{payload_len}"),
CancellationToken::new(),
),
)
.await
.expect("prepare boundary request");
assert_eq!(stream.metadata().transport().as_str(), expected);
}
assert_eq!(server.received_requests().await.unwrap().len(), 1);
}
#[tokio::test]
async fn oversized_ws_new_chain_uses_http_but_warmup_and_http_continuation_keep_websocket() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let server = MockServer::start().await;
let provider = provider_with_base_url(&store, server.uri());
let body = Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!([])),
(
"instructions".to_owned(),
json!("x".repeat(16 * 1024 * 1024)),
),
("store".to_owned(), json!(false)),
]);
for case in ["downstream_websocket", "warmup", "native_continuation"] {
let mut body = body.clone();
let mut request_context = Map::new();
let mut attempt = context(&format!("req_large_{case}"), CancellationToken::new());
match case {
"downstream_websocket" => {
request_context.insert(
"downstream_websocket_connection_id".to_owned(),
json!("ws_large"),
);
async fn advertised_size_fallback_preserves_next_request_websocket_for_all_credentials() {
for api_key in [false, true] {
for downstream_websocket in [false, true] {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut websocket =
crate::transport::accept_codex_test_websocket_with(socket, |_, response| {
response
.headers_mut()
.insert("x-codex-websocket-max-message-bytes", "1".parse().unwrap());
})
.await;
while let Some(Ok(message)) = websocket.next().await {
assert!(!message.is_text(), "size fallback must precede upload");
}
let (mut http, _) = listener.accept().await.unwrap();
let request = crate::transport::read_http_request_with_body(&mut http).await;
assert!(request.starts_with(b"POST "));
http.write_all(format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
CAPTURE_COMPLETED_SSE.len(), CAPTURE_COMPLETED_SSE,
).as_bytes()).await.unwrap();
drop(http);
let (socket, _) = listener.accept().await.unwrap();
let mut websocket =
crate::transport::accept_codex_test_websocket_with(socket, |_, response| {
response.headers_mut().insert(
"x-codex-websocket-max-message-bytes",
"131072".parse().unwrap(),
);
})
.await;
assert!(websocket.next().await.unwrap().unwrap().is_text());
websocket
.send(Message::Text(
crate::transport::completed_websocket_response("resp_small", 1, 1).into(),
))
.await
.unwrap();
});
let store = Arc::new(MemoryAccountStore::default());
if api_key {
store
.seed_api_key(
"acct_provider_contract",
base_url.clone(),
provider_openai::credential::ResponsesTransport::PreferWebsocket,
)
.await;
} else {
create_account(&store, "acct_provider_contract").await;
}
"warmup" => {
body.insert("generate".to_owned(), json!(false));
}
"native_continuation" => {
body.insert("previous_response_id".to_owned(), json!("client_previous"));
attempt = pinned_continuation_context(
"req_large_native_continuation",
"acct_provider_contract",
"client_previous",
"upstream_previous",
1,
ContinuationAttempt::Native,
);
}
_ => unreachable!(),
}
let mut generate = GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body)
.unwrap()
.with_context(request_context),
);
if case == "native_continuation" {
generate = generate.with_provider_session_state(
ProviderSessionState::new(
let provider = provider_with_base_url(&store, base_url);
for index in 0..2 {
let mut payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("account_id".to_owned(), json!("acct_provider_contract")),
("continuation_scope".to_owned(), json!("connection_local")),
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!([])),
("session_id".to_owned(), json!("size-fallback-session")),
("store".to_owned(), json!(false)),
]),
)
.unwrap(),
);
.unwrap();
if downstream_websocket {
payload = payload.with_context(Map::from_iter([(
"downstream_websocket_connection_id".to_owned(),
json!(format!("ws_size_{index}")),
)]));
}
let mut stream = Arc::clone(&provider)
.execute(
planned_request(
"openai",
Operation::Generate(
GenerateRequest::from_protocol_payload(payload)
.with_provider_session_state(
ProviderSessionState::new(
"openai",
Map::from_iter([
(
"account_id".to_owned(),
json!("acct_provider_contract"),
),
(
"conversation_id".to_owned(),
json!("size-fallback-conversation"),
),
(
"continuation_scope".to_owned(),
json!("persisted"),
),
]),
)
.unwrap(),
),
),
),
context(&format!("req_size_{index}"), CancellationToken::new()),
)
.await
.unwrap();
let mut completed = false;
while let Some(event) = stream.next().await {
let event = event.unwrap();
if let Some(state) = event.session_update() {
assert_eq!(state.prefers_websocket_reconnect(), index == 0);
}
completed |= event
.wire_event()
.is_some_and(|wire| wire.event_type() == Some("response.completed"));
}
assert!(completed);
}
server.await.unwrap();
}
let stream = Arc::clone(&provider)
.execute(
planned_request("openai", Operation::Generate(generate)),
attempt,
)
.await
.expect("prepare required WebSocket request");
let expected = if case == "downstream_websocket" {
"http_sse"
} else {
"websocket"
};
assert_eq!(stream.metadata().transport().as_str(), expected, "{case}");
}
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn oversized_exact_websocket_continuation_requests_client_replay_before_send() {
async fn advertised_size_limit_on_reused_connection_requests_client_replay_before_send() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut websocket =
crate::transport::accept_codex_test_websocket_with(socket, |_, response| {
response.headers_mut().insert(
"x-codex-websocket-max-message-bytes",
"2048".parse().unwrap(),
);
})
.await;
assert!(websocket.next().await.unwrap().unwrap().is_text());
websocket
.send(Message::Text(
crate::transport::completed_websocket_response("resp_size_seed", 1, 1).into(),
))
.await
.unwrap();
while let Some(Ok(message)) = websocket.next().await {
assert!(
!message.is_text(),
"oversized continuation must not be uploaded"
);
}
assert!(
tokio::time::timeout(Duration::from_millis(100), listener.accept())
.await
.is_err()
);
});
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let server = MockServer::start().await;
let provider = provider_with_base_url(&store, server.uri());
for input_bytes in [128, 16 * 1024 * 1024] {
let payload = ProtocolPayload::json_object("openai", Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!([{"type": "function_call_output", "call_id": "call_large", "output": "x".repeat(input_bytes)}])),
("previous_response_id".to_owned(), json!("client_previous")),
("store".to_owned(), json!(false)),
])).unwrap().with_context(Map::from_iter([
("downstream_websocket_connection_id".to_owned(), json!("ws_native_large")),
]));
let generate = GenerateRequest::from_protocol_payload(payload).with_provider_session_state(
let provider = provider_with_base_url(&store, base_url);
let mut body = Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!([])),
("session_id".to_owned(), json!("size-native-session")),
("store".to_owned(), json!(false)),
]);
let operation = |body: Map<String, Value>, state: Option<ProviderSessionState>| {
let payload = ProtocolPayload::json_object("openai", body)
.unwrap()
.with_context(Map::from_iter([(
"downstream_websocket_connection_id".to_owned(),
json!("ws_size_native"),
)]));
let mut generate = GenerateRequest::from_protocol_payload(payload);
generate = generate.with_provider_session_state(state.unwrap_or_else(|| {
ProviderSessionState::new(
"openai",
Map::from_iter([
("account_id".to_owned(), json!("acct_provider_contract")),
("continuation_scope".to_owned(), json!("connection_local")),
(
"conversation_id".to_owned(),
json!("size-native-conversation"),
),
("continuation_scope".to_owned(), json!("persisted")),
]),
)
.unwrap(),
);
let result = Arc::clone(&provider)
.execute(
planned_request("openai", Operation::Generate(generate)),
pinned_continuation_context(
&format!("req_native_size_{input_bytes}"),
"acct_provider_contract",
"client_previous",
"upstream_previous",
1,
ContinuationAttempt::Native,
),
)
.await;
if input_bytes == 128 {
assert_eq!(
result
.expect("small native continuation")
.metadata()
.transport()
.as_str(),
"websocket"
);
} else {
let error = result
.err()
.expect("large native continuation requires full client input");
assert_eq!(
error.kind(),
ProviderErrorKind::ContinuationRecoveryRequired
);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert_eq!(
error.continuation_unavailable_reason(),
Some("transport_payload_too_large")
);
// 本地大小校验的客户端恢复提示不能伪装成已收到的上游拒绝
assert!(error.upstream_status().is_none());
assert!(error.upstream_code().is_none());
assert!(error.raw_upstream_error().is_none());
assert_eq!(
error.continuation_recovery_disposition(),
Some(ContinuationRecoveryDisposition::ClientReplayRequired)
);
assert_eq!(
error.client_visible_upstream_error().unwrap().code(),
Some("previous_response_not_found")
);
assert!(!error.allows_pre_delivery_retry());
}
}
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn oversized_api_key_new_chains_preserve_explicit_websocket_transport() {
let upstream = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
"acct_provider_contract",
upstream.uri(),
provider_openai::credential::ResponsesTransport::PreferWebsocket,
)
.await;
let provider = provider_with_base_url(&store, upstream.uri());
for downstream_websocket in [false, true] {
let mut payload = ProtocolPayload::json_object(
"openai",
Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!([])),
(
"instructions".to_owned(),
json!("x".repeat(16 * 1024 * 1024)),
),
("store".to_owned(), json!(false)),
]),
)
.unwrap();
if downstream_websocket {
payload = payload.with_context(Map::from_iter([(
"downstream_websocket_connection_id".to_owned(),
json!("ws_api_large"),
)]));
}
let stream = Arc::clone(&provider)
.execute(
planned_request(
"openai",
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
),
context(
&format!("req_large_api_new_{downstream_websocket}"),
CancellationToken::new(),
),
)
.await
.expect("API Key new chain preserves configured transport");
assert_eq!(stream.metadata().transport().as_str(), "websocket");
}
assert!(upstream.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn oversized_api_key_native_continuation_preserves_websocket_transport() {
let upstream = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
"acct_provider_contract",
upstream.uri(),
provider_openai::credential::ResponsesTransport::PreferWebsocket,
)
.await;
let payload = ProtocolPayload::json_object("openai", Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
("input".to_owned(), json!([{"type": "function_call_output", "call_id": "call_api_large", "output": "x".repeat(16 * 1024 * 1024)}])),
("previous_response_id".to_owned(), json!("client_api_previous")),
("store".to_owned(), json!(false)),
])).unwrap().with_context(Map::from_iter([
("downstream_websocket_connection_id".to_owned(), json!("ws_api_large")),
]));
let generate = GenerateRequest::from_protocol_payload(payload).with_provider_session_state(
ProviderSessionState::new(
"openai",
Map::from_iter([
("account_id".to_owned(), json!("acct_provider_contract")),
("continuation_scope".to_owned(), json!("connection_local")),
]),
)
.unwrap(),
);
let stream = provider_with_base_url(&store, upstream.uri())
.unwrap()
}));
Operation::Generate(generate)
};
let mut first = Arc::clone(&provider)
.execute(
planned_request("openai", Operation::Generate(generate)),
planned_request("openai", operation(body.clone(), None)),
context("req_size_seed", CancellationToken::new()),
)
.await
.unwrap();
let mut state = None;
while let Some(event) = first.next().await {
if let Some(update) = event.unwrap().session_update() {
state = Some(update.clone());
}
}
drop(first);
let state = state.expect("connection-local continuation state");
assert_eq!(state.payload()["continuation_scope"], "connection_local");
body.insert("previous_response_id".to_owned(), json!("resp_size_seed"));
body.insert(
"input".to_owned(),
json!([{"type":"function_call_output","call_id":"call_size","output":"x".repeat(4096)}]),
);
let mut continuation = provider
.execute(
planned_request("openai", operation(body, Some(state))),
pinned_continuation_context(
"req_large_api_native",
"req_size_continuation",
"acct_provider_contract",
"client_api_previous",
"upstream_api_previous",
"resp_size_seed",
"resp_size_seed",
1,
ContinuationAttempt::Native,
),
)
.await
.expect("API Key native continuation is not rejected by the Codex size policy");
assert_eq!(stream.metadata().transport().as_str(), "websocket");
assert!(upstream.received_requests().await.unwrap().is_empty());
.unwrap();
let error = loop {
if let Err(error) = continuation.next().await.expect("continuation rejection") {
break error;
}
};
assert_eq!(
error.kind(),
ProviderErrorKind::ContinuationRecoveryRequired
);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert_eq!(
error.continuation_unavailable_reason(),
Some("transport_payload_too_large")
);
assert_eq!(
error.continuation_recovery_disposition(),
Some(ContinuationRecoveryDisposition::ClientReplayRequired)
);
assert_eq!(
error.client_visible_upstream_error().unwrap().code(),
Some("previous_response_not_found")
);
assert!(error.upstream_status().is_none());
assert!(error.raw_upstream_error().is_none());
assert!(!error.allows_pre_delivery_retry());
drop(continuation);
server.await.unwrap();
}
#[tokio::test]
@@ -5665,9 +5502,20 @@ async fn oversized_websocket_http_response_continues_with_client_tool_history_re
json!(connection_id),
)]));
let mut generate = GenerateRequest::from_protocol_payload(payload);
if let Some(state) = state {
generate = generate.with_provider_session_state(state);
}
generate = generate.with_provider_session_state(state.unwrap_or_else(|| {
ProviderSessionState::new(
"openai",
Map::from_iter([
("account_id".to_owned(), json!("acct_provider_contract")),
(
"conversation_id".to_owned(),
json!("size-native-conversation"),
),
("continuation_scope".to_owned(), json!("persisted")),
]),
)
.unwrap()
}));
Operation::Generate(generate)
}
@@ -5690,14 +5538,11 @@ async fn oversized_websocket_http_response_continues_with_client_tool_history_re
}
}))
};
ResponseTemplate::new(200).insert_header("content-type", "text/event-stream").set_body_string(event)
ResponseTemplate::new(200).insert_header("content-type", "text/event-stream").insert_header("connection", "close").set_body_string(event)
}).expect(2).mount(&server).await;
let mut body = Map::from_iter([
("model".to_owned(), json!("gpt-5.4")),
(
"instructions".to_owned(),
json!("x".repeat(16 * 1024 * 1024)),
),
("instructions".to_owned(), json!("x".repeat(4096))),
(
"input".to_owned(),
json!([{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "inspect"}]}]),
@@ -5709,7 +5554,30 @@ async fn oversized_websocket_http_response_continues_with_client_tool_history_re
("session_id".to_owned(), json!("large-ws-http-session")),
("store".to_owned(), json!(false)),
]);
let provider = provider_with_base_url(&store, server.uri());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let http_address = *server.address();
let upstream = tokio::spawn(async move {
for _ in 0..2 {
let (socket, _) = listener.accept().await.unwrap();
let mut websocket =
crate::transport::accept_codex_test_websocket_with(socket, |_, response| {
response
.headers_mut()
.insert("x-codex-websocket-max-message-bytes", "1".parse().unwrap());
})
.await;
while let Some(Ok(message)) = websocket.next().await {
assert!(!message.is_text());
}
let (mut incoming, _) = listener.accept().await.unwrap();
let mut outgoing = TcpStream::connect(http_address).await.unwrap();
tokio::io::copy_bidirectional(&mut incoming, &mut outgoing)
.await
.unwrap();
}
});
let provider = provider_with_base_url(&store, base_url);
let mut first = Arc::clone(&provider)
.execute(
planned_request("openai", ws_operation(body.clone(), "ws_first", None)),
@@ -5717,7 +5585,6 @@ async fn oversized_websocket_http_response_continues_with_client_tool_history_re
)
.await
.expect("prepare large WS request over HTTP");
assert_eq!(first.metadata().transport().as_str(), "http_sse");
let mut state = None;
let mut output = None;
while let Some(event) = first.next().await {
@@ -5782,7 +5649,6 @@ async fn oversized_websocket_http_response_continues_with_client_tool_history_re
)
.await
.expect("client full history replay over HTTP");
assert_eq!(replay.metadata().transport().as_str(), "http_sse");
let mut completed = false;
while let Some(event) = replay.next().await {
completed |= event
@@ -5797,6 +5663,7 @@ async fn oversized_websocket_http_response_continues_with_client_tool_history_re
assert_eq!(sent["input"], body["input"]);
assert_eq!(sent["tools"], body["tools"]);
assert!(sent.get("previous_response_id").is_none());
upstream.await.unwrap();
}
#[tokio::test]
@@ -11,7 +11,8 @@ async fn responses_options_follow_the_selected_upstream_on_both_transports() {
"truncation": "disabled", "stop_sequences": [], "prompt_cache_options": {}
});
let mut body = json!({
"model": "gpt-5.4", "input": "hello", "max_tokens": 16,
"model": "gpt-5.4", "input": "hello",
"max_output_tokens": 1, "max_completion_tokens": 32, "max_tokens": 64,
"stream_options": {"include_usage": true, "reasoning_summary_delivery": "sequential_cutoff"},
"client_metadata": {"user": "keep"},
"tools": [{"type": "function", "name": "sample", "parameters": {
@@ -30,17 +31,19 @@ async fn responses_options_follow_the_selected_upstream_on_both_transports() {
"{field}, api_key={api_key}, websocket={websocket}"
);
}
for field in ["max_output_tokens", "max_completion_tokens", "max_tokens"] {
assert!(
sent.get(field).is_none(),
"{field}, api_key={api_key}, websocket={websocket}"
);
}
if api_key {
assert_eq!(sent["stream_options"], body["stream_options"]);
assert_eq!(sent["max_tokens"], 16);
assert!(sent.get("max_output_tokens").is_none());
} else {
assert_eq!(
sent["stream_options"],
json!({"reasoning_summary_delivery": "sequential_cutoff"})
);
assert_eq!(sent["max_output_tokens"], 16);
assert!(sent.get("max_tokens").is_none());
}
assert_eq!(sent["client_metadata"]["user"], "keep");
assert_eq!(sent["tools"], body["tools"]);
@@ -49,25 +52,15 @@ async fn responses_options_follow_the_selected_upstream_on_both_transports() {
}
#[tokio::test]
async fn codex_oauth_keeps_explicit_limits_and_context_options() {
for (native, completion, expected) in [
(json!(16), json!(32), json!(16)),
(Value::Null, json!(32), json!(32)),
(Value::Null, Value::Null, json!(64)),
] {
let body = json!({
"model": "gpt-5.4", "input": "hello", "max_output_tokens": native,
"max_completion_tokens": completion, "max_tokens": 64,
"truncation": "auto", "stop_sequences": ["end"],
"prompt_cache_options": {"future_option": true}
});
let sent = capture_downstream_request(body.clone(), false, false, false).await;
assert_eq!(sent["max_output_tokens"], expected);
assert!(sent.get("max_completion_tokens").is_none());
assert!(sent.get("max_tokens").is_none());
for key in ["truncation", "stop_sequences", "prompt_cache_options"] {
assert_eq!(sent[key], body[key]);
}
async fn codex_oauth_keeps_explicit_context_options() {
let body = json!({
"model": "gpt-5.4", "input": "hello",
"truncation": "auto", "stop_sequences": ["end"],
"prompt_cache_options": {"future_option": true}
});
let sent = capture_downstream_request(body.clone(), false, false, false).await;
for key in ["truncation", "stop_sequences", "prompt_cache_options"] {
assert_eq!(sent[key], body[key]);
}
}
@@ -1659,6 +1659,7 @@ async fn preferred_final_thread_rewrite_within_the_session_preserves_the_primary
in_flight: 100,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -1888,6 +1889,7 @@ async fn preferred_primary_migration_distinguishes_temporary_and_persistent_unav
in_flight: 2,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -2013,6 +2015,7 @@ async fn preferred_spillover_stays_stable_and_concurrent_migration_claims_one_pr
in_flight: 100,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -1155,6 +1155,7 @@ async fn guardian_reservation_survives_upstream_adapters_and_metadata_precedence
in_flight: 2,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -957,6 +957,7 @@ impl ProviderLeasePort for TestLeaseCoordinator {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -454,3 +454,55 @@ async fn socks_proxy_flush_contract_coalesces_greeting_without_losing_auth_or_co
}
}
}
#[tokio::test]
async fn unbound_websocket_uses_authenticated_environment_proxy() {
if std::env::var_os("CPR_TEST_WS_ENV_ROUTE").is_some() {
let base = CodexBackendClient::new(
provider_openai::transport::build_reqwest_client().unwrap(),
"http://upstream.invalid",
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let mut request = codex_request("gpt-5.5", "", Vec::new());
request.set_previous_response_id(Some("resp_previous".to_owned()));
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
let response = timeout(
Duration::from_secs(5),
base.create_response(&request, request_context("env-ws", None)),
)
.await
.unwrap()
.unwrap();
assert!(response.body.contains("resp_env"));
return;
}
let (proxy, observation) = websocket_exit("resp_env").await;
let output = tokio::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"transport::account_proxy::unbound_websocket_uses_authenticated_environment_proxy",
])
.env("CPR_TEST_WS_ENV_ROUTE", "1")
.env("ALL_PROXY", proxy)
.env("HTTP_PROXY", "")
.env("HTTPS_PROXY", "")
.env("NO_PROXY", "localhost")
.env_remove("REQUEST_METHOD")
.output()
.await
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stdout)
);
let head = timeout(Duration::from_secs(2), observation)
.await
.unwrap()
.unwrap();
assert_eq!(
read_header_value(&head, "Proxy-Authorization"),
Some("Basic dXNlcjpwYXNz")
);
}
@@ -37,6 +37,155 @@ fn explicit_websocket_warmup_request(conversation_id: &str) -> CodexResponsesReq
request
}
#[tokio::test]
async fn advertised_websocket_limit_counts_final_utf8_bytes_and_allows_equality() {
let mut payload_len = 0;
for case in [
"absent", "invalid", "negative", "overflow", "equal", "below", "zero", "large",
] {
let mut request = codex_request(
"gpt-5.5",
if case == "large" {
"x".repeat(17 * 1024 * 1024)
} else {
"中文\n\"\\".to_owned()
},
Vec::new(),
);
request.use_websocket = true;
request.local_conversation_id = Some("advertised-limit".to_owned());
let limit = match case {
"absent" => None,
"invalid" => Some("invalid".to_owned()),
"negative" => Some("-1".to_owned()),
"overflow" => Some("184467440737095516160".to_owned()),
"equal" => Some(payload_len.to_string()),
"below" => Some((payload_len - 1).to_string()),
"zero" => Some("0".to_owned()),
"large" => Some((128 * 1024 * 1024).to_string()),
_ => unreachable!(),
};
let fallback = matches!(case, "below" | "zero");
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket_with(socket, |_, response| {
if let Some(limit) = limit {
response.headers_mut().insert(
"x-codex-websocket-max-message-bytes",
limit.parse().unwrap(),
);
}
})
.await;
if fallback {
while let Some(Ok(message)) = websocket.next().await {
assert!(!message.is_text(), "oversized payload must not be uploaded");
}
let (mut http, _) = listener.accept().await.unwrap();
let request = read_http_request_with_body(&mut http).await;
assert!(request.starts_with(b"POST /codex/responses HTTP/1.1"));
write_completed_sse_response(&mut http).await;
0
} else {
let message = websocket.next().await.unwrap().unwrap();
let text = message.to_text().unwrap();
let length = text.len();
assert_eq!(
serde_json::from_str::<Value>(text).unwrap()["type"],
"response.create"
);
websocket
.send(Message::Text(
completed_websocket_response("resp_limit", 1, 1).into(),
))
.await
.unwrap();
length
}
});
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
);
let response = timeout(
Duration::from_secs(10),
backend.create_response(
&request,
request_context("req_advertised_limit", Some("chatgpt-account")),
),
)
.await
.unwrap()
.unwrap();
assert_eq!(
response.transport,
if fallback {
CodexBackendTransport::HttpSse
} else {
CodexBackendTransport::WebSocket
},
"{case}"
);
if fallback {
assert_eq!(
response.transport_metrics.decision,
Some(CodexTransportDecision::Http2WebSocketMessageTooLarge)
);
}
let actual_len = server.await.unwrap();
if case == "absent" {
payload_len = actual_len;
}
if case == "equal" {
assert_eq!(actual_len, payload_len);
}
}
}
#[tokio::test]
async fn advertised_websocket_limit_rejects_warmup_without_http_fallback() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket_with(socket, |_, response| {
response
.headers_mut()
.insert("x-codex-websocket-max-message-bytes", "1".parse().unwrap());
})
.await;
while let Some(Ok(message)) = websocket.next().await {
assert!(!message.is_text());
}
assert!(
timeout(Duration::from_millis(100), listener.accept())
.await
.is_err()
);
});
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::default()));
let error = backend
.create_response(
&explicit_websocket_warmup_request("oversized-warmup"),
request_context("req_oversized_warmup", Some("chatgpt-account")),
)
.await
.unwrap_err();
std::assert_matches!(
error,
CodexClientError::WebSocket(CodexWebSocketExchangeError::MessageTooLarge)
);
server.await.unwrap();
}
async fn reject_websocket_openings(listener: &TcpListener, attempts: usize) {
for _ in 0..attempts {
let (mut opening, _) = listener.accept().await.unwrap();
@@ -524,3 +524,48 @@ fn release(version: &str, build: &str) -> CodexDesktopRelease {
signature_present: true,
}
}
#[tokio::test]
async fn official_appcast_uses_the_deployment_https_proxy() {
use provider_openai::transport::profile::OfficialCodexDesktopReleaseTransport;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
if std::env::var_os("CPR_TEST_APPCAST_PROXY").is_some() {
let transport = OfficialCodexDesktopReleaseTransport::new().unwrap();
assert!(transport.fetch().await.is_err());
return;
}
let server = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut command = tokio::process::Command::new(std::env::current_exe().unwrap());
command
.args([
"--exact",
"transport::profile::official_appcast_uses_the_deployment_https_proxy",
])
.env("CPR_TEST_APPCAST_PROXY", "1")
.env(
"HTTPS_PROXY",
format!("http://{}", server.local_addr().unwrap()),
)
.env("HTTP_PROXY", "")
.env("ALL_PROXY", "")
.env("NO_PROXY", "")
.env_remove("REQUEST_METHOD");
let observe = async {
let (socket, _) = server.accept().await.unwrap();
let mut socket = BufReader::new(socket);
let mut line = String::new();
socket.read_line(&mut line).await.unwrap();
assert_eq!(line, "CONNECT persistent.oaistatic.com:443 HTTP/1.1\r\n");
socket
.get_mut()
.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
.await
.unwrap();
};
let (output, ()) = tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(command.output(), observe)
})
.await
.unwrap();
assert!(output.unwrap().status.success());
}
+1
View File
@@ -15,6 +15,7 @@ bytes = "1.9.0"
chrono = { version = "0.4.45", features = ["clock", "serde"] }
futures = "0.3.32"
gateway-admin = { path = "../../gateway-admin" }
gateway-egress = { path = "../../gateway-egress" }
gateway-core = { path = "../../gateway-core" }
gateway-protocol = { path = "../../gateway-protocol" }
getrandom = "0.4.3"
@@ -7,8 +7,8 @@ use std::time::{Duration, Instant, SystemTime};
use chrono::{DateTime, Utc};
use gateway_core::account::{
AccountQuotaSignals, CredentialRevision, ProviderAccount, ProviderAccountId, QuotaObservation,
QuotaState,
AccountQuotaResetWindow, AccountQuotaSignals, CredentialRevision, ProviderAccount,
ProviderAccountId, QuotaObservation, QuotaResetPeriod, QuotaState,
};
use tokio::sync::Mutex;
@@ -180,7 +180,7 @@ struct GrokQuotaProjectionState {
entries: BTreeMap<ProviderAccountId, GrokQuotaSchedulingEntry>,
}
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone)]
struct GrokQuotaSchedulingEntry {
version: u64,
revision: CredentialRevision,
@@ -317,7 +317,7 @@ impl GrokQuotaSchedulingProjection {
.filter(|entry| {
entry.revision == account.revision() && Instant::now() < entry.expires_at
})
.and_then(|entry| entry.signals)
.and_then(|entry| entry.signals.clone())
}
}
@@ -585,8 +585,20 @@ fn quota_scheduling_signals(billing: &GrokBillingPresentation) -> Option<Account
})
.flatten();
let reset_at = period_end.filter(|reset_at| *reset_at > now);
(remaining_rank.is_some() || reset_at.is_some())
.then(|| AccountQuotaSignals::new(reset_at, remaining_rank))
(remaining_rank.is_some() || reset_at.is_some()).then(|| {
let signals = AccountQuotaSignals::new(reset_at, remaining_rank);
let period = match billing.period_kind() {
GrokQuotaPeriodKind::Weekly => Some(QuotaResetPeriod::Weekly),
GrokQuotaPeriodKind::Monthly => Some(QuotaResetPeriod::Monthly),
GrokQuotaPeriodKind::Other => None,
};
match (period, reset_at) {
(Some(period), Some(reset_at)) if billing.has_authoritative_quota() => {
signals.with_reset_windows(vec![AccountQuotaResetWindow { period, reset_at }])
}
_ => signals,
}
})
}
/// 把 xAI billing 文档归一化为访问结论
@@ -545,7 +545,7 @@ impl GrokCredentialRefreshService {
{
return Err(GrokCredentialRefreshError::Preparation);
}
// 直连发现失败不能阻止其他出口上的账号刷新
// 默认出口发现失败不能阻止其他出口上的账号刷新
credentials.retain(|credential| {
if credential.outbound_proxy.is_some() {
return true;
@@ -126,7 +126,7 @@ impl<'a> TokenVerificationContext<'a> {
self
}
/// 返回本次验证的显式出口;`None` 表示直连,不得继承环境代理
/// 返回本次验证的显式出口;`None` 表示使用部署默认出口
#[must_use]
pub fn outbound_proxy(&self) -> Option<&gateway_core::account::OutboundProxy> {
self.outbound_proxy
@@ -694,6 +694,10 @@ fn build_official_client(
timeout: Option<Duration>,
proxy: Option<&OutboundProxy>,
) -> Result<Client, GrokReqwestTransportBuildError> {
let target = Url::parse(&format!("https://{}", dns_policy.allowed_host))
.map_err(|_| GrokReqwestTransportBuildError::ClientInitialization)?;
let proxy = gateway_egress::for_destination(proxy, &target)
.map_err(|_| GrokReqwestTransportBuildError::ClientInitialization)?;
let mut builder = Client::builder()
// 工作区可能同时启用 native-tls;与官方 Grok CLI 一样显式固定 rustls,避免握手画像漂移
.use_rustls_tls()
@@ -129,9 +129,9 @@ impl OfficialGrokCliReleaseTransport {
pub fn new() -> Result<Self, GrokCliReleaseError> {
let endpoint =
Url::parse(GROK_CLI_RELEASE_URL).map_err(|_| GrokCliReleaseError::InvalidEndpoint)?;
let client = Client::builder()
let client = gateway_egress::http_client(None)
.map_err(|_| GrokCliReleaseError::ClientInitialization)?
.https_only(true)
.no_proxy()
.redirect(Policy::none())
.connect_timeout(Duration::from_secs(10))
.timeout(RELEASE_TIMEOUT)
@@ -827,6 +827,7 @@ impl ProviderLeasePort for TestLeases {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -301,6 +301,47 @@ async fn billing_access_and_scheduling_use_current_quota_fields() {
}
}
#[tokio::test]
async fn billing_projects_only_recognized_periods_with_quota_evidence() {
use gateway_core::account::{AccountQuotaResetWindow, AccountQuotaSignals, QuotaResetPeriod};
let end = "2099-08-01T00:00:00Z";
let reset_at = SystemTime::from(chrono::DateTime::parse_from_rfc3339(end).unwrap());
for (kind, used, expected) in [
(
"USAGE_PERIOD_TYPE_WEEKLY",
Some(25),
Some(QuotaResetPeriod::Weekly),
),
(
"USAGE_PERIOD_TYPE_MONTHLY",
Some(25),
Some(QuotaResetPeriod::Monthly),
),
("USAGE_PERIOD_TYPE_MONTHLY", None, None),
("USAGE_PERIOD_TYPE_DAILY", Some(25), None),
] {
let (store, repository) = repository_with_accounts(&[("period", "period")]).await;
let mut config = serde_json::json!({"currentPeriod":{"type":kind,"end":end}});
if let Some(percent) = used {
config["creditUsagePercent"] = percent.into();
}
let body = serde_json::to_vec(&serde_json::json!({"config":config})).unwrap();
let service =
crate::support::grok_quota_service(repository, QueueBillingTransport::success(&body));
let id = account_id("period");
service.refresh_account(&id).await.unwrap();
let mut signals = AccountQuotaSignals::new(Some(reset_at), used.map(|_| 7500));
if let Some(period) = expected {
signals =
signals.with_reset_windows(vec![AccountQuotaResetWindow { period, reset_at }]);
}
assert_eq!(
service.scheduling_signals(&store.account(&id).unwrap()),
Some(signals)
);
}
}
#[tokio::test]
async fn signed_prepaid_balance_preserves_wire_and_recovers_access_when_current() {
use gateway_core::account::QuotaAccessState::{Allowed, Exhausted};
@@ -140,6 +140,7 @@ impl SelectorFixture {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -1855,6 +1855,7 @@ impl ProviderLeasePort for DiagnosticLeasePort {
in_flight: 0,
last_started_at: None,
quota_reset_at: None,
quota_reset_windows: None,
quota_remaining_rank: None,
cooldown: None,
failure_rate_basis_points: None,
@@ -1418,3 +1418,42 @@ fn resolve_with_policy(
}
}
}
#[tokio::test]
async fn official_xai_resolves_https_proxy_host_outside_the_direct_dns_allowlist() {
use tokio::io::{AsyncBufReadExt, BufReader};
if std::env::var_os("CPR_TEST_XAI_PROXY").is_some() {
let client = OfficialGrokEndpointPolicy
.build_oauth_client(Some(Duration::from_secs(2)), None)
.unwrap();
assert!(client.get("https://auth.x.ai/").send().await.is_err());
return;
}
let server = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
command.args(["--exact", "transport::network::official_xai_resolves_https_proxy_host_outside_the_direct_dns_allowlist"])
.env("CPR_TEST_XAI_PROXY", "1")
.env("HTTPS_PROXY", format!("http://localhost:{}", server.local_addr().unwrap().port()))
.env("HTTP_PROXY", "").env("ALL_PROXY", "").env("NO_PROXY", "").env_remove("REQUEST_METHOD");
let observe = async {
let (socket, _) = server.accept().await.unwrap();
let mut socket = BufReader::new(socket);
let mut line = String::new();
socket.read_line(&mut line).await.unwrap();
assert_eq!(line, "CONNECT auth.x.ai:443 HTTP/1.1\r\n");
socket
.get_mut()
.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
.await
.unwrap();
};
let (output, ()) = tokio::time::timeout(Duration::from_secs(5), async {
tokio::join!(
tokio::task::spawn_blocking(move || command.output()),
observe
)
})
.await
.unwrap();
assert!(output.unwrap().unwrap().status.success());
}
+16 -4
View File
@@ -433,9 +433,21 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`
“修改未添加项”只带回明确未添加的条目;连接测试失败的代理已经保存,可在列表中重新测试。
请求中断或结果无法确认时暂停后续提交,添加结果待确认的条目可能已保存,应先刷新列表核对,不能直接重复添加
创建或编辑账号时,在“出站隧道”选择直连或已保存的代理,点击右侧的“测试出口”图标。
“部署默认出口”表示跟随网关进程的环境。账号上游请求、插件受管网络和网关下载优先使用业务指定的代理,只有未指定时才读取 `HTTP_PROXY`(HTTP/WS)或 `HTTPS_PROXY`(HTTPS/WSS)。缺少对应协议的代理时使用 `ALL_PROXY`;命中 `NO_PROXY` 或没有适用的代理时直连。环境变量支持小写,大写优先。选中的代理连接失败会报错,不会回退直连。
使用 Docker Compose 时,把环境变量加到 `codex-proxy-rs` 服务已有的 `environment` 下,修改后重新创建该容器。无需修改 CPR 的 `config.yaml` 或 Docker daemon 的代理设置。例如,让网关通过代理访问外部 HTTPS 服务,同时直连公司内网域名:
```yaml
services:
codex-proxy-rs:
environment:
HTTPS_PROXY: "http://proxy.example:3128"
NO_PROXY: "localhost,127.0.0.1,::1,.corp.example"
```
创建或编辑账号时,在“出站隧道”选择部署默认出口或已保存的代理,点击右侧的“测试出口”图标。
耗时显示在对应选项右侧,悬浮可查看 IPv4 / IPv6,测试失败通过通知提示。
禁用代理的账号使用服务器直连出口。出口测试不保存账号设置,也不验证账号权限或额度;账号可用性使用账号连接测试
禁用代理的账号使用部署默认出口。出口测试不保存账号设置,也不验证账号权限或额度;账号可用性使用账号连接测试
在代理列表点击关联账号数量,再点击“分配账号”,搜索并跨页勾选账号后点击右上角的“分配”,一次最多 1000 个。
已使用此代理的账号不可重复选择;所选账号原有代理会被替换,凭据、分组与调度设置保留。禁用代理须先启用
@@ -798,7 +810,7 @@ OAuth 恢复开关为 `host.logging.oauth_recovery`,默认关闭,与普通
### 插件取文与 DNS
插件受管 HTTP 由实际运行网关的主机或容器解析 DNS,再通过直连或账号代理访问目标。插件拥有完整网络访问能力,宿主不按插件身份限制私网、回环或保留地址
插件受管 HTTP 由实际运行网关的主机或容器解析 DNS,再通过所选代理或部署默认出口访问目标。插件拥有完整网络访问能力,宿主不按插件身份限制私网、回环或保留地址
域名解析失败时先核对网关运行环境的 DNS。Clash / Mihomo 的 Fake-IP 地址需要相应代理接管;无法连通时可让目标域名返回真实地址,并检查所选代理。代理失败不会自动回退直连
@@ -1003,7 +1015,7 @@ OpenAI 主动额度重置卡及消费结果由上游持有,不属于本地备
- 插件包体、固定来源、下载凭据、制品接受事实、实例配置、版本配置快照、敏感配置、功能范围绑定和私有状态都保存在
PostgreSQL,随数据库一起备份。插件通过宿主回调读写的内置平台账号保存在同库的通用账号表。
来源使用的出站代理及其认证也须随库恢复,不能只备份 `plugin_*` 表;来源代理与账号代理独立选择,
不依赖进程代理环境,故障时不会自动回退直连。
未指定时使用部署默认出口,故障时不会自动回退直连。
包体单个上限为 32 MiB,需要计入数据库及备份容量;解压运行目录和 Redis 协调状态是可重建数据,
不作为插件安装或制品接受事实。恢复时保留制品接受状态、实例配置、账号引用和状态代次的一致性;插件降级仍须
通过状态兼容校验,不通过手工替换缓存目录回滚
+25 -23
View File
@@ -343,12 +343,12 @@ Chat 沿用 [Client Key 鉴权](#数据面客户端接口)、[请求期限](#请
[请求头与身份](#请求头与身份)及[隐私策略](#隐私策略)。隐私策略的 `request_body` 面向目标上游正文,
Responses 上游使用 `$.input`,Messages 上游使用 `$.messages`
转换后的请求按选中的 Provider 执行。OpenAI 的字段处理见[正文兼容](#正文兼容);
输出上限原值交给上游校验,低于模型下限时会收到上游错误
转换后的请求按选中的 Provider 执行。OpenAI Responses 上游忽略客户端的输出上限,见[正文兼容](#正文兼容)
### Messages 请求
`POST /v1/messages` 接受非空字符串 `model`、非空 `messages` 数组和非负整数 `max_tokens`。
转向 OpenAI Responses 上游时,`max_tokens` 仍为入站必填字段,但不限制实际输出。
消息角色为 `user`、`assistant` 或 `system`,`content` 为字符串或内容块数组;初始系统指令放在顶层 `system`,
会话中的 `system` 消息保留其位置,具体模型支持情况由上游校验。
`stream` 缺省或 `false` 返回 JSON,`true` 返回 SSE。每轮由客户端携带所需的完整上下文。Claude Code 用 `--resume` 恢复本地会话后,仍通过 `messages` 发送上下文;网关不缓存对话正文用于下一轮。
@@ -538,8 +538,9 @@ Images 的 `x-codex-image-turn-id` 和 Desktop 自动 Git 上下文的 `clientUs
#### 正文兼容
OpenAI Provider 的 Responses 上游编码会移除 Codex 不接受的顶层 `temperature` 和 `prompt_cache_retention`,
并保留调用方指定的 `max_output_tokens`。缺少顶层 `store` 时补齐 `false`,与官方 Codex 客户端一致;
发往 OpenAI Responses 上游(OAuth 或 API Key)的生成请求会省略顶层 `max_output_tokens`、`max_completion_tokens`
和 `max_tokens`,输出可能超过客户端指定的上限。编码时也会移除 Codex 不接受的顶层 `temperature`
和 `prompt_cache_retention`。缺少顶层 `store` 时补齐 `false`,与官方 Codex 客户端一致;
显式提供的值保持原样。顶层 `input` 为字符串时按公开 Responses API 的语义展开为一条
`user` 文本消息条目(`{"type": "message", "role": "user", "content": [{"type": "input_text", ...}]}`),
因为 Codex 后端只接受条目数组;数组及其他类型原样透传。`input` 数组中显式指定 `type: "message"`
@@ -556,7 +557,6 @@ OpenAI Provider 的 Responses 上游编码会移除 Codex 不接受的顶层 `te
| `top_p`、`frequency_penalty`、`presence_penalty`、`chat_template_kwargs`、`user`、`metadata`、`safety_identifier` | 省略;采样参数取默认值时也省略 |
| `stream_options.include_usage` | 省略该成员,保留其他流式选项 |
| `truncation`、`stop_sequences`、`prompt_cache_options` | 分别仅在值为 `"disabled"`、`[]`、`{}` 时省略;显式策略交给上游校验 |
| `max_completion_tokens`、`max_tokens` | 转为 `max_output_tokens`。原生字段优先,其次按左列顺序取首个非 `null` 值 |
Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品牌区分:显式 `type: "reasoning"`
的 `input` 项移除顶层 `status`;该项具有非空字符串 `encrypted_content` 时,还会移除非空数组
@@ -637,13 +637,13 @@ OAuth 账号默认 `prefer_websocket`,客户端使用 HTTP/SSE 时仍可能选
`400 unsupported_prewarm`,不会将其当作普通生成调用。`generate=true` 或省略该字段正常执行;
客户端可跳过预热继续正式请求,外部插件 adapter 按各自的协议能力处理
Codex OAuth backend 的候选上游为 WS 时,无 `previous_response_id` 的普通新链若规范化
`response.create` 达到 15.5 MiB,发送前选择 HTTP/SSE;HTTP 和 WS 入站均适用。能关联到客户端线程时,
后续完整新链沿用 HTTP/SSE,直到帧长低于 13.5 MiB 再尝试 WS。预热和增量帧不改变此大小判断。
该阈值为已观察到的上游消息大小边界预留余量,不是网关输入长度上限或 OpenAI 公布的统一限制;
API Key 账号、小请求、显式 warmup、未知外部 previous ID 和持久化续接不因该大小策略改用 HTTP
OpenAI Responses 上游 WS 按握手响应头 `x-codex-websocket-max-message-bytes` 声明的上限,
检查最终 `response.create` 文本的 UTF-8 字节数。超过上限时,新链、持久化续接和未知外部 previous ID
在上传正文前改用 HTTP/SSE;等于上限仍可发送。OAuth 和 API Key 账号、HTTP 和 WS 入站均适用。
该上限只属于当前上游连接;响应头缺失或无效时不预判超限。显式 warmup 保持 WS 合同,超限时
返回 `websocket_message_too_large`,不重复发送或改用 HTTP
OAuth 的大 connection-local WS 续接,以及 HTTP `store=false` 成功后标记为 `ReplayRequired`
超过当前连接上限的 connection-local WS 续接,以及 HTTP `store=false` 成功后标记为 `ReplayRequired`
的 WS 增量,均在发送前返回 `status: 400 / previous_response_not_found`。客户端应清除旧 ID,
携带完整历史及工具调用/输出重试。官方 Codex 支持重连 WS 后完整重发,也可能按自身重试预算
切到 HTTP;其他客户端需要自行实现此合同。代理不缓存 transcript,不把增量输入当作独立新链,
@@ -1297,7 +1297,7 @@ OpenAI 主动额度刷新和正常响应携带的明确套餐会同步到账号
`outboundProxyId` 绑定已保存的代理,不要求出口测试成功;省略或 `null` 保留当前绑定,空字符串清除绑定。
`outboundProxyUrl` 兼容 HTTP、HTTPS、SOCKS5、SOCKS5H 代理 URL,可带用户名和密码;不能与 ID 同时设置。
编辑时省略或 `null` 表示保持原配置,空字符串表示清除代理并直连。列表和详情返回绑定的
编辑时省略或 `null` 表示保持原配置,空字符串表示清除绑定并使用部署默认出口。列表和详情返回绑定的
`outboundProxyId`(未绑定时为 `null`,禁用代理仍保留 ID)和不含认证信息的 `outboundProxyEndpoint`
(未配置时为 `null`);只有显式敏感导出包含完整 URL。
指定代理后,推理、OAuth 服务端交换/刷新及账号辅助请求使用同一出口;代理失败不会退回直连。
@@ -1408,11 +1408,11 @@ Search 的选号限制不约束插件在选号后主动改写正文。
| `POST` | `/api/admin/proxies/accounts/remove` | `{ proxyId, accountId }` | `{ configRevision }` |
| `POST` | `/api/admin/proxies/create` | `[{ name, proxyUrl, location?, autoLocation?, maxWebsocketConnections? }]` | 逐项结果数组,成功项的 `data` 为 `{ record, configRevision }` |
| `POST` | `/api/admin/proxies/update` | `{ id, revision, name, enabled?, proxyUrl?, location?, autoLocation?, maxWebsocketConnections? }` | `{ record, configRevision }` |
| `POST` | `/api/admin/proxies/probe` | `{ proxyUrl, detectLocation? }` | 测试未保存的地址或服务器直连出口,返回连通性及可选位置结果,不创建代理 |
| `POST` | `/api/admin/proxies/probe` | `{ proxyUrl, useDeploymentProxy?, detectLocation? }` | 测试未保存的地址、部署默认出口或服务器直连出口,返回连通性及可选位置结果,不创建代理 |
| `POST` | `/api/admin/proxies/test` | `[{ id, revision, detectLocation? }]` | 逐项结果数组,成功项的 `data` 为最新代理记录 |
| `POST` | `/api/admin/proxies/delete` | `{ id, revision }` | `{ configRevision }` |
`probe` 的 `proxyUrl` 必填,空字符串表示服务器直连;非空值须为合法代理 URL,`null`、省略或非法地址均拒绝。
`probe` 的 `proxyUrl` 必填,空字符串默认测试服务器直连,同时传 `useDeploymentProxy: true` 则测试部署默认出口。指定代理地址时不能同时传 `useDeploymentProxy: true`;非空值须为合法代理 URL,`null`、省略或非法地址均拒绝。
探测不创建代理或修改账号绑定
添加与测试统一接收 1–100 项数组,单条操作传一个元素,响应 `data` 按输入顺序返回等长数组。
@@ -1437,9 +1437,9 @@ Search 的选号限制不约束插件在选号后主动改写正文。
`upstream` 复用账号列表的互联观测结构和发现状态语义,只读取本地观测,不触发远端刷新
移除关联账号只清除指定账号的代理绑定与连接地址,使其改为直连,保留凭据、调度参数与分组。
移除关联账号只清除指定账号的代理绑定与连接地址,使其使用部署默认出口,保留凭据、调度参数与分组。
若账号已不再绑定请求中的代理,则返回 409
删除代理会自动解除全部关联账号的代理绑定与连接地址,账号保留其余设置并改为直连。
删除代理会自动解除全部关联账号的代理绑定与连接地址,账号保留其余设置并使用部署默认出口。
代理仍被插件来源或插件包引用时返回 409,账号绑定保持不变
`maxWebsocketConnections` 为此代理的 OpenAI Responses 上游 WS 连接上限,范围 0–4294967295,0 表示不限制。
@@ -1449,7 +1449,7 @@ Search 的选号限制不约束插件在选号后主动改写正文。
更新省略 `proxyUrl` 保留认证;连接配置改变时清除测试结果并更新所有绑定账号。
测试结果只在请求中的版本仍匹配时保存
代理默认启用,更新 `enabled: false` 后保留配置、测试结果和账号绑定,关联账号的新请求改为直连,
代理默认启用,更新 `enabled: false` 后保留配置、测试结果和账号绑定,关联账号的新请求使用部署默认出口,
不应用该代理的位置覆盖;重新启用后恢复原出口及位置规则。省略 `enabled` 保留当前状态。
禁用代理仍可执行连接测试,但不能以 ID 选择用于账号绑定、新的账号授权、导入或插件下载
@@ -2346,16 +2346,18 @@ Chat Completions 和 Responses 通过原生 OpenAI Provider 选号时,缺少
"healthWeight": 1.0,
"latencyWeight": 0.5,
"resetWeight": 0.0,
"queueWeight": 0.0
"queueWeight": 0.0,
"resetWindowWeights": { "fiveHour": 0.0, "weekly": 0.0, "monthly": 0.0 }
}
```
六项系数分别调整负载、剩余额度、健康、首个有效输出延迟、额度重置和排队压力的评分偏好,范围 `0~10`、最多一位小数,
至少一项大于 `0`。`0` 仅关闭对应评分维度,不放宽账号资格、额度或并发限制;系数不表示流量百分比。
缺字段、`null`、未知字段及无效数值均返回 `422`,不写入配置。读取设置额外返回只读
缺少必填系数、系数为 `null`、未知字段及无效数值均返回 `422`,不写入配置。读取设置额外返回只读
`smartSchedulingDefaults`,内容为上述默认对象,用于恢复默认与自定义状态比较,不可提交到更新接口
`resetWeight` 越大越偏向即将重置额度的账号,复用已有有效重置时间,未知或已过期时不加分。
`resetWeight` 控制重置分在总评分中的比重。`resetWindowWeights` 中的 `fiveHour`、`weekly`、`monthly` 分别表示 5 小时、周和月的相对系数。三项均为 0~10、最多一位小数。默认均为 0,旧配置省略整个字段时也取 0;全部为 0 时不计重置分。设为 `{"fiveHour":0,"weekly":1,"monthly":0}` 可只按周重置评分。只计算账号通用窗口,未知、缺失或已过期的周期贡献为零;独立的 `quota_reset_priority` 策略仍按最早有效重置时间排序
`queueWeight` 只在没有可立即使用的账号、需要选择等待队列时参与评分,越大越偏向等待人数少的账号。
设为 `0` 时沿用最短队列规则;启用后结合其余五项评分选择队列。队列人数限于当前进程,只有开启账号排队才会生效,
不影响已入队请求的位置、队内 FIFO、容量上限或等待超时。两项默认均为 `0`
@@ -3194,7 +3196,7 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
来源 URL 使用 HTTPS,不包含用户信息、查询参数或片段;回环地址允许 HTTP,供本机来源使用
查询、校验和远程安装的 `outboundProxyId` 指向已保存的出站代理,与下载凭据独立,公开来源也可选择代理。
省略或 `null` 表示直连,不继承进程环境代理;代理失败不会回退直连。查询与下载期间代理配置变化时返回 `409`,需重新操作。
省略或 `null` 表示使用部署默认出口;代理失败不会回退直连。查询与下载期间代理配置变化时返回 `409`,需重新操作。
查询缓存与限流按凭据及代理身份隔离。制品历史 `source.outbound_proxy` 只保存下载时的 `{ id, revision }`,不返回代理地址或认证信息。
仍被更新来源或制品引用的代理不能删除;修改后续来源不会清除已安装制品的历史引用
@@ -3203,7 +3205,7 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
修改更新来源请求为 `{ "pluginId": "acme.example", "source": { "kind": "github", "repository": "owner/repo" }, "policy": { "kind": "stable" }, "outboundProxyId": null }`;
其他可选来源为 `upload` 或带 `url` 的 `url`。`policy` 省略时为 `manual`;GitHub 还支持 `stable`(最新稳定版)和
`{ "kind": "pinned", "tag": "v2.0.0-rc.1", "allow_prerelease": true }`。其他来源只支持 `manual`。
来源、策略和代理选择一同持久化与审计;代理字段省略或 `null` 明确保存为直连。`upload` 和 `builtin` 不接受代理。
来源、策略和代理选择一同持久化与审计;代理字段省略或 `null` 保存为部署默认出口。`upload` 和 `builtin` 不接受代理。
变更不改变已安装包的历史来源,不自动下载、接受制品或切换实例版本
检查更新请求为 `{ "pluginId": "acme.example", "credentialIds": [] }`,返回 `binding` 和 `release`。
@@ -3283,7 +3285,7 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta
回调保持类型、期限、取消、实例 revision、资源归属和事务一致性检查。模型调用可显式选择 Key,按所选 Key 的准入、预算、用量与计费合同执行;省略 Key 时继承现有模型父请求身份
受管 HTTP 不限制插件目标地址段。宿主解析 DNS,连接保留原始 Host 与 TLS 域名校验;出口可继承调用范围、指定账号或按代理 ID 选择,连接失败不自动回退直连,重定向不自动跟随。
受管 HTTP 不限制插件目标地址段。宿主解析 DNS,连接保留原始 Host 与 TLS 域名校验;出口可继承调用范围、指定账号或按代理 ID 选择,无显式代理时使用部署默认出口,连接失败不自动回退直连,重定向不自动跟随。
显式代理选择与目录查询见 [SDK 网络回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#网络)
#### 能力绑定与范围
+10 -9
View File
@@ -79,6 +79,7 @@ flowchart TB
| `gateway-admin` | 管理领域、Key 用量、互联共享查询与上游同步、Provider/Store 端口、审计语义、备份与历史保留策略 |
| `gateway-api` | HTTP/WS/SSE 解码与交付、Admin 与 Key 用量 wire、静态 Web UI;不直接访问 Store 或具体 Provider |
| `gateway-store` | PostgreSQL、Redis、S3/R2、`pg_dump` 适配器;不拥有业务策略 |
| `gateway-egress` | Host 与 Provider 共用的 HTTP / WebSocket 出口选择,按目标应用环境代理和绕过规则 |
| `gateway-host` | 配置加载、日志、HTTP 生命周期、Worker 监督与历史保留任务、系统更新、互联 HTTP 与外部价格源适配 |
| `gateway-plugin/sdk`(包名 `gateway-plugin-sdk`) | 公开插件清单与线协议,独立于网关领域;异步收发通过可选 `io` feature 提供 |
| `gateway-plugin/runtime`(包名 `gateway-plugin-runtime`) | 插件包校验、能力适配、双向 RPC 与发布集合;通过 Host 管理子进程和受管 HTTP |
@@ -623,8 +624,8 @@ xAI Provider 负责 Codex custom 工具与 Grok function 工具的双向转换
客户端配置示例维护在 [部署文档](../deploy/README.md#客户端配置)
客户端到代理与代理到上游的传输选择相互独立。客户端的 `supports_websockets = false`
不禁止 Provider 使用上游 WebSocket。OpenAI Provider 按线程独立维护大包回退与 WS 失败预算,
以终态会话更新提示下游重连;API 负责确认成功写出和入站队列空闲。响应终态前的 Close 1000 仍视为失败,
不禁止 Provider 使用上游 WebSocket。OpenAI Provider 按线程维护 WS 失败预算,消息大小上限随上游
物理连接保存。超限回退以终态会话更新提示下游重连;API 负责确认成功写出和入站队列空闲。响应终态前的 Close 1000 仍视为失败,
后续恢复请求成功也不改写原失败请求的结果
## 6. 路由、账号范围与 continuation
@@ -673,9 +674,9 @@ OpenAI 只使用未满 30 分钟且尚未收到变化通知的账号目录排除
### 智能调度
Core 定义、校验并统一拥有评分规则,内置选号与诊断复用同一函数;Store 随运行设置和配置 revision 原子保存,
快照编译器将其纳入 `AccountSelectionPolicy`。近似最优容差随立即选号的五项系数总和缩放
快照编译器将其纳入 `AccountSelectionPolicy`。近似最优容差随立即选号的有效系数总和缩放
- 额度重置使用 Provider 的有效重置时间,未知或已过期时间不加分
- 额度重置按 5 小时、周、月分别评分。Provider 复用已有额度快照的周期分类,只投影账号通用窗口。每个周期取最近的未来重置,以周期的七分之一为尺度 `H`,得分为 `H / (H + 剩余秒数)`。三个周期按所配系数取加权平均,缺失、未知或过期不加分,分母不随账号变化;三项默认为 0,此时重置不参与评分及容差计算
- 排队系数只在选择等待队列时结合其他评分和进程内实时等待人数,不影响立即选号分数与容差;系数为零时选最短队列。
`ConcurrencyWaitQueue` 在同一锁内读取队长并入队,保持已有位置、同级 FIFO、取消回收、容量和共享等待预算;Provider 传入候选须已限定续写范围
- 健康分按已发送尝试的结果计算账号级平滑失败率并随时间衰减;明确容量拒绝的平滑权重为 0.4,其他可计分失败和成功为 0.2
@@ -692,9 +693,9 @@ Core 定义、校验并统一拥有评分规则,内置选号与诊断复用同
Continuation 受原请求的 Client Key、账号范围、Provider 和发送/交付边界约束:
- native continuation 固定创建它的 Provider 与账号
- OpenAI Provider 的 OAuth 大包传输预检在发送前执行,新链可选择 HTTP;大 connection-local
- OpenAI Provider 在发送前按上游握手声明的消息大小上限检查最终出站帧,新链可选择 HTTP;超限的 connection-local
续接和 HTTP `store=false` 后不可原生续写的 WS 增量返回 `ClientReplayRequired`,由客户端重发
完整历史。体积测量复用 WS 编码结构,Provider session state 仍只携带元数据,不保存 transcript
完整历史。预检拒绝先释放连接及租约,不消耗 WS 故障预算;Provider session state 只携带元数据,不保存 transcript
- OpenAI 在有效输出交付前收到可安全重放的明确额度拒绝时,先隔离账号,再投影 `ClientReplayRequired`;
丢弃未交付的原错误帧,由客户端提交完整历史开启新链,不把原增量输入交给其他账号。
真实错误分类、状态码、发送状态和上游诊断保持不变,客户端合同见 [Responses API](api.md#3-模型数据面与模型目录)
@@ -1035,7 +1036,7 @@ Admin 仅为开启自动使用的账号保存卡片 ID、到期时间、下一
### 账号出站代理
出站代理属于账号配置,由各 Provider 在推理、OAuth 服务端交换/刷新、额度、目录和资料等请求中统一使用。
启用的指定代理不可用时请求失败,不退回直连;未配置或代理被禁用时直连,不继承进程的全局代理环境变量,TLS 仍验证证书。
显式指定的代理优先,连接失败即报错。未绑定或账号所绑代理被禁用时使用部署默认出口:按目的协议匹配网关进程的 `HTTP_PROXY` / `HTTPS_PROXY`,其次使用 `ALL_PROXY`;命中 `NO_PROXY` 或没有适用代理时直连。WebSocket 按对应的 HTTP 协议匹配;TLS 仍验证证书。
用户浏览器访问第三方授权页时的出口不受网关设置控制
连接池按出口隔离。修改或启停代理推进配置 revision,使后续请求使用新出口;已执行请求可沿原连接完成。
@@ -1047,12 +1048,12 @@ client,OIDC 的 JWKS 缓存与单飞归属对应出口状态。凭据变更后
出口测试结果属于诊断信息,不作为选择、授权或导入绑定的准入条件。
双栈出口并发检测,延迟对应首选可用出口的 HTTP 探测耗时(IPv4 优先),不包含另一地址族失败的等待;
检测仍等待两个地址族完成或超时,以保留双栈可用性结果。两者均失败时,延迟为整体检测耗时。
代理启停由代理记录独立持有;关闭时保留绑定、连接配置和位置规则,账号执行投影改为直连,
代理启停由代理记录独立持有;关闭时保留绑定、连接配置和位置规则,账号执行使用部署默认出口,
不使用该代理的位置覆盖,重新启用后恢复原出口。
连接配置改变时在同一事务内同步关联账号并清除旧测试结果。
删除代理在同一事务中校验版本、解除全部账号绑定并写入审计与配置 revision,账号其余事实及凭据版本保持不变。
关联账号支持单独移除:在原子更新中校验当前代理 ID,只清除账号的代理 ID 和 URL,
保留账号其他设置;提交配置版本与审计后复用快照发布流程,使后续请求使用直连。
保留账号其他设置;提交配置版本与审计后复用快照发布流程,使后续请求使用部署默认出口。
代理目录只携带关联数量;账号明细通过独立分页读端口按需查询,
在同一个只读快照中统计并读取有界页面,不在目录中聚合完整账号数组。
文件及 AT/RT 导入在上游凭据交换前取得 PostgreSQL 会话级共享咨询锁,直到凭据提交后释放。
+1 -1
View File
@@ -25,7 +25,7 @@ flowchart LR
- **GitHub**:填写仓库地址或 `owner/repo`;标签留空查询最新稳定版,预发行版须指定标签
公开来源无需认证。私有来源可选择或创建独立下载认证;认证按来源和用途限制,不会交给插件进程。
下载代理独立选择,默认直连,代理失败不会回退直连。SHA-256 用于固定内容,不证明发布者身份
下载代理独立选择,未指定时使用部署默认出口,代理失败不会回退直连。SHA-256 用于固定内容,不证明发布者身份
“下一步”只解析和校验包,不保存制品。检查名称、发布者、版本、平台和摘要后确认安装。随宿主发行物导入的官方制品会显示为“待接受”,也必须先确认信任来源,
未接受的制品不能创建或编辑配置。相同摘要的确认可安全重试
+7 -11
View File
@@ -27,8 +27,7 @@
- **Seed 与 Map 可追踪**:主色和功能色均按 Ant Design 从 Seed 派生 P1-P10;浅色 P6 等于 Seed,深色 P6
经过暗色色板适配。全局 `colorPrimary` 保留原始 Seed;功能色 Base 保留 P6。文字和按钮组件可在派生层做对比度校正,Seed 不随之改写
- **主色与表面分离**:品牌色负责交互和强调,页面、容器、浮层与文字由独立的背景和文本 Seed 派生
- **语义独立**:`success`、`warning`、`error`、`info` 不随品牌色隐式改变;未显式配置 `link`
时跟随 `primary`,显式配置后独立派生
- **语义独立**:成功、警告、错误、信息和链接色各自派生,不随品牌色改变
- **无边设计**:默认依靠表面色差、间距和轻阴影表达层级;边框只用于焦点、错误和必要分隔
- **运行时可定制**:用户输入在浏览器中实时派生,因此使用 CSS Variables,不使用构建时 SCSS 变量
- **单一事实源**:Store 只保存最小配置,所有 Map、Alias 与未覆盖的 Component Token 均由纯函数生成
@@ -158,7 +157,7 @@ interface ThemeCustomization {
表格行高与卡片圆角通过 `customization.component` 的 `tableRowHeight`、`cardBorderRadius` 配置,属于组件尺寸覆盖
完整主题预设组合品牌配色、推荐材质、全局 Seed、组件尺寸和可编辑 Token。选择时替换草稿配置,保留当前明暗模式;保存后应用到管理端
切换内置主题预设会更新草稿中的品牌色、材质、尺寸与风格,保留明暗模式以及「功能色与中性色」的设置;保存后应用到管理端
表面材质可单独选择:`default` 使用简洁表面,`soft` 使用柔和光影。材质只影响表面高光、渐变与阴影,不改变配色、圆角、字号或组件尺寸
@@ -206,10 +205,8 @@ Preset 的实心色使用 P6,普通 Container、Strong Container 与边界按
- `colorText / Heading / Secondary / Tertiary / Quaternary / Disabled`
- `colorBorder / BorderSecondary / Split / Shadow`
星津夜航使用稳定的浅色 / 深色中性基线;水墨江汀、粉墙回廊与朱阙金瓦预置额外提供各自的 `colorBgBase`、`colorTextBase`
画像,自定义主色则只向通用中性基线注入少量色温。默认浅色采用中性锚点,默认深色使用 HSL 色调派生;带色温
主题按外观距离平滑过渡到背景和文字 Seed 的 RGB 混色结果。稳定锚点用于 Surface 与 Shadow,Component 从角色派生,
所有预置、自定义 Seed 和用户覆盖仍进入同一条算法,不在页面或组件中追加 HEX 特判。
所有品牌色共用当前明暗模式的背景和文字基色。手动设置 `colorBgBase` 或 `colorTextBase` 后,两个模式都使用该字段的自定义值;单项恢复后重新跟随明暗模式。
默认浅色采用中性锚点,默认深色使用 HSL 色调派生;自定义中性色按外观距离平滑过渡到背景和文字 Seed 的 RGB 混色结果。
Input、阴影与其他 Component Token 从 Fill、Surface、Primary 和 Semantic 派生,不在常量文件维护整套颜色表
正常文字同时检查 Layout、Container、Elevated、文字交互背景、三级 Fill,以及控件透明填充在各宿主上的合成色
@@ -291,7 +288,7 @@ Purple Strong Container 或 Purple Solid;同一组颜色仍由运行时色板
| ID | 名称 | 推荐品牌色 | 表面 |
| --- | --- | --- | --- |
| `default` | 默认 | 星津夜航 | 简洁表面与轻量阴影 |
| `soft` | 柔光 | 星津夜航 | 表面高光与分层阴影,配色跟随品牌 |
| `soft` | 柔光 | 星津夜航 | 表面高光、渐变与分层阴影 |
### 主题预设
@@ -302,8 +299,7 @@ Purple Strong Container 或 Purple Solid;同一组颜色仍由运行时色板
| `signal-violet` | 粉墙回廊 | `#D395A8` | 默认材质,14px 正文与 68px 表格行高 |
| `graphite` | 朱阙金瓦 | `#A34537` | 柔光,12px 卡片圆角与 54px 表格行高 |
品牌配色改变品牌主色,并可提供与该品牌匹配的浅色 / 深色背景和文字 Seed;不改变字号、密度、圆角或布局。完整预设提供的 Seed 优先于品牌基线,用户覆盖优先于预设
所有预置与自定义主题走同一条 Surface、Map、Alias 与 Component 派生链路,不存在页面级特判
单独编辑品牌主色不改变尺寸和风格。预置与自定义主题使用相同的 Surface、Map、Alias 与 Component 派生链路
## 运行时架构
@@ -448,7 +444,7 @@ utility / arbitrary variant;Vue Transition、跨浏览器 Range、动态富文
### 增加预置主题
在 UI 库的 `themeProfiles` 中组合配色 ID 对应的材质与 `ThemeCustomization`。设置页将其复制为可编辑草稿,保存实际配置,无需持久化额外的选中标记。材质仍由组件 Token 派生层实现
在 UI 库的 `themeProfiles` 中为每个配色 ID 配置材质与 `ThemeCustomization`。内置预设不指定功能色与中性色。设置页通过 `resolveThemeProfile` 应用预设,保留预设未指定的颜色;选中状态由同一结果推导。材质由组件 Token 派生层实现
单独增加品牌配色:
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.9.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.10.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.70.0",
+6 -6
View File
@@ -195,8 +195,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.45(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.9.1
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/46ade64479009ab9bc7cb1c8ec6d143fbd0a3b6c(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.45(typescript@6.0.3))(yaml@2.9.0)
specifier: github:zyycn/codex-proxy-ui#v0.10.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/45b076735d67f973588d93144078c7605cf1ade7(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.45(typescript@6.0.3))(yaml@2.9.0)
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -456,9 +456,9 @@ packages:
resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==}
engines: {node: '>= 20.12.0'}
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/46ade64479009ab9bc7cb1c8ec6d143fbd0a3b6c':
resolution: {gitHosted: true, integrity: sha512-4gsO8MKvqlYmi1+TbgVt0r2EabvYzPJ2YpvhrLlyQz0EPX+jhA6mK0ZXOlDRSSs7/AIZ8Ihfi+zvVFfSIVLdxg==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/46ade64479009ab9bc7cb1c8ec6d143fbd0a3b6c}
version: 0.9.1
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/45b076735d67f973588d93144078c7605cf1ade7':
resolution: {gitHosted: true, integrity: sha512-TyY1/D8rzd1MwU5HUbKOEzca5q7nPqydWaRpb5iMUrSy6G4dXleZNSZd6VXAum9WXZrZGb9ZzBm9cOMIOg+/xg==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/45b076735d67f973588d93144078c7605cf1ade7}
version: 0.10.0
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -3530,7 +3530,7 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/46ade64479009ab9bc7cb1c8ec6d143fbd0a3b6c(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.45(typescript@6.0.3))(yaml@2.9.0)':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/45b076735d67f973588d93144078c7605cf1ade7(axios@1.21.0(debug@4.4.3))(change-case@5.4.4)(jiti@2.7.0)(postcss@8.5.30)(vite@8.3.6(@types/node@26.6.6)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.45(typescript@6.0.3))(yaml@2.9.0)':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
+1
View File
@@ -107,6 +107,7 @@ interface ProxyTestParam extends ProxyIdParam {
}
interface ProxyProbeParam {
useDeploymentProxy?: boolean
proxyUrl: string
detectLocation?: boolean
}
@@ -30,6 +30,7 @@ export interface SmartSchedulingConfig {
latencyWeight: number
resetWeight: number
queueWeight: number
resetWindowWeights: { fiveHour: number, weekly: number, monthly: number }
}
export interface RuntimeSettings {
+4 -4
View File
@@ -249,15 +249,15 @@
"proxy": {
"keepCurrent": "Keep current",
"keepExisting": "Keep each account's connection settings",
"directConnection": "Direct",
"notUsingProxy": "No proxy",
"deploymentDefault": "Deployment default",
"deploymentDefaultHelp": "Use the server’s proxy settings",
"disabledSuffix": "(disabled)",
"testFailed": "(test failed)",
"notTested": "(not tested)",
"outboundTunnel": "Outbound tunnel",
"testExit": "Test egress",
"selectToTest": "Select a direct connection or managed proxy to test egress",
"disabledUsesDirect": "The proxy is disabled, so the test uses the server’s direct connection",
"selectToTest": "Select an egress route to test",
"disabledUsesDefault": "Proxy disabled; the test uses the deployment default",
"connected": "Egress connected · {latencyMs} ms",
"testIncomplete": "No test result received, run the test again"
},
+1 -1
View File
@@ -409,7 +409,7 @@
"completed": "Rolled back to {version} and restored its settings"
},
"sourceProxy": {
"noProxyDirectConnection": "No proxy (direct connection)",
"deploymentDefault": "Deployment default",
"disabledSuffix": "(disabled)",
"catalogUnavailable": "Selected proxy, directory unavailable",
"label": "Download proxy"

Some files were not shown because too many files have changed in this diff Show More