mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-12 16:06:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ad8fb1c4 | ||
|
|
4888681a04 | ||
|
|
9739bb08be | ||
|
|
fac37ec563 | ||
|
|
9f0d712671 | ||
|
|
75be1287ef | ||
|
|
dad91fe9f7 | ||
|
|
b22b0cfa41 |
Generated
+14
@@ -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",
|
||||
|
||||
@@ -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 != ¤t.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),
|
||||
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -25,7 +25,7 @@ flowchart LR
|
||||
- **GitHub**:填写仓库地址或 `owner/repo`;标签留空查询最新稳定版,预发行版须指定标签
|
||||
|
||||
公开来源无需认证。私有来源可选择或创建独立下载认证;认证按来源和用途限制,不会交给插件进程。
|
||||
下载代理独立选择,默认直连,代理失败不会回退直连。SHA-256 用于固定内容,不证明发布者身份
|
||||
下载代理独立选择,未指定时使用部署默认出口,代理失败不会回退直连。SHA-256 用于固定内容,不证明发布者身份
|
||||
|
||||
“下一步”只解析和校验包,不保存制品。检查名称、发布者、版本、平台和摘要后确认安装。随宿主发行物导入的官方制品会显示为“待接受”,也必须先确认信任来源,
|
||||
未接受的制品不能创建或编辑配置。相同摘要的确认可安全重试
|
||||
|
||||
+7
-11
@@ -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 派生层实现
|
||||
|
||||
单独增加品牌配色:
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+6
-6
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user