Compare commits

...
11 Commits
Author SHA1 Message Date
ZyyandCodex 6c56a23508 chore(release): v3.23.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 16:51:52 +08:00
b5f3cb402c fix(openai): reject sends on closed WebSocket connections (#381)
Record observed closure before terminal delivery can block, reject queued
unsent commands, and skip commands cancelled before socket writes.
Preserve connection diagnostics and test account egress across quota writes.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 16:36:40 +08:00
57e284d7d1 fix(openai): preserve stream recovery before semantic output (#380)
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 16:30:26 +08:00
21597f9e98 fix(store): restore observation query optimization (#378)
Scope timing projection barriers to filtered metric and diagnostic queries, preserving ordinary aggregate index and parallel plans.

Refs #377

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 16:30:05 +08:00
ZyyandCodex fa0b87dd0a refactor(frontend): adopt UI v0.4.0 component APIs
Migrate the management UI to Z components and managed notifications, register the library loading directive, and align fixed dependencies and submodules with the published UI version.

Co-authored-by: Codex <noreply@openai.com>
2026-10-10 14:54:56 +08:00
ZyyandCodex 389dc63633 chore(release): v3.23.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-10 00:45:56 +08:00
ZyyandCodex 78b2250faa feat(settings): add configurable Codex privacy policies (#375)
Add configurable privacy rules and shared previews for Codex metadata, Desktop context, request bodies and headers. Freeze policy per request and preserve atomic failure handling across HTTP and WebSocket transports.

Include settings presets, persistence, API contracts, migration and transport regressions.

Co-authored-by: Codex <noreply@openai.com>
2026-10-10 00:41:25 +08:00
ZyyandCodex bbb8278281 fix(openai): enforce image account eligibility (#374)
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 22:57:24 +08:00
ZyyandCodex 7a5521953f fix(openai): align Desktop client time context overrides
Apply configured request locations to Desktop developer time contexts alongside environment contexts while preserving role and content-kind boundaries.

Cover date alignment, disabled overrides, proxy precedence, and HTTP/WebSocket requests. Update the location contract and troubleshooting documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 20:53:59 +08:00
f78b1ca487 feat(openai): allow HTTP upstream API key accounts (#372)
Allow HTTP API key base URLs for private networks and container services while retaining OAuth startup URL restrictions. Keep URL credential, query and fragment validation, and cover import, export and address rotation.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 20:41:53 +08:00
ZyyandCodex 661649cc90 refactor(compat): remove expired upgrade and plugin adapters
Use the current host manifest format and require restart confirmation on
all management UI paths. Document v3.18.1 as the minimum online upgrade
source while retaining old release manifest reads for supported rollbacks.

Retire middleware v3 and upstream_adapter v1 Fast projections after their
compatibility window. Align SDK and host declarations with the current
contracts, keep risk warnings and per-instance failure isolation, and
exercise native fast_mode values through real plugin subprocesses.

Keep deployment, API and SDK documentation focused on current behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 20:41:53 +08:00
318 changed files with 7713 additions and 3278 deletions
@@ -6,7 +6,7 @@
## 清单与处理器
- `publisher` 与机器短名 `name` 派生插件 ID,`displayName` 用于展示;复制示例后同时调整扩展项 ID 与注册结果
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件必须声明版本 `3` 并显式选择阶段。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- `contributes` 每种能力最多一项,普通贡献项可省略派生 ID、能力版本和固定阶段;中间件必须声明版本 `4` 并显式选择阶段,上游适配器必须声明版本 `2`。自定义完整扩展项 ID 属于自己的插件命名空间,运行注册与规范化清单一致
- 作者清单用 `Manifest::from_author_slice` 规范化,不手写 `package`、固定阶段或重复注册;CLI 生成平台、协议与资源摘要,版本范围不能使用全版本通配
- 安装后宿主会按 `configurationSchema` 准备默认配置,配置完整即可启用;默认绑定的空范围不限制请求。需要业务参数时声明真实必填项,通过 `secretFields` 声明敏感字段,不依赖用户再走一遍自建安装向导
- Rust 插件只依赖公开 `gateway-plugin-sdk`;需要异步会话辅助时开启 `io`,使用 `PluginSession` 管理握手、回调关联、流控、取消和关闭
-2
View File
@@ -177,8 +177,6 @@ jobs:
host_path = "backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json"
host = JSON.parse(File.read(host_path))
abort "Invalid plugin host compatibility declaration: #{host_path}" unless host["schema_version"] == 2
# 保留旧更新器可解析的传输格式,实际支持范围仍取自当前宿主合同。
host = host.merge("schema_version" => 1, "permissions" => [])
# 清单只依赖版本与兼容声明,平台归档和镜像消费同一份封口结果。
manifest = {
"schema_version" => 1,
+1
View File
@@ -3289,6 +3289,7 @@ dependencies = [
"native-tls",
"openssl",
"openssl-sys",
"regex",
"reqwest",
"roxmltree",
"rustls",
@@ -45,6 +45,7 @@ pub use gateway_core::account::RotationStrategy;
/// 运行设置的共同值;版本、Provider 画像更新和数据库秘密由外层类型拥有
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RuntimeSettingsValues {
pub codex_privacy_policy: gateway_core::settings::privacy::CodexPrivacyPolicy,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub refresh_margin_seconds: u64,
@@ -130,6 +130,32 @@ struct ProviderAdminDiagnostic {
/// 运行时资源通知只在事务成功后发生
#[async_trait]
pub trait ProviderAdmin: Send + Sync {
fn compile_privacy_policy(
&self,
_policy: &gateway_core::settings::privacy::CodexPrivacyPolicy,
) -> Result<
Arc<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
gateway_core::settings::privacy::PrivacyError,
> {
Err(gateway_core::settings::privacy::PrivacyError {
rule_index: 0,
reason: "Provider 不支持隐私策略",
})
}
fn preview_privacy_policy(
&self,
_request: gateway_core::settings::privacy::PrivacyPreviewRequest,
) -> Result<
gateway_core::settings::privacy::PrivacyPreviewResult,
gateway_core::settings::privacy::PrivacyError,
> {
Err(gateway_core::settings::privacy::PrivacyError {
rule_index: 0,
reason: "Provider 不支持隐私策略",
})
}
/// 只读内置价目;没有本地计价能力的 Provider 返回空目录
fn pricing_catalog(&self) -> crate::model::pricing::ProviderPricingCatalog {
Default::default()
@@ -25,6 +25,13 @@ pub(super) fn register(
}};
}
register!(Load, load, (), ());
register!(
PreviewPrivacyPolicy,
preview_privacy_policy,
gateway_core::settings::privacy::PrivacyPreviewRequest,
request,
request
);
register!(
Replace,
replace,
@@ -22,6 +22,13 @@ use super::{map_store_error, publish_committed};
/// API 消费的 Runtime settings 管理服务
#[async_trait]
pub trait SettingsService: Send + Sync {
async fn preview_privacy_policy(
&self,
_request: gateway_core::settings::privacy::PrivacyPreviewRequest,
) -> Result<gateway_core::settings::privacy::PrivacyPreviewResult, AdminError> {
Err(AdminError::invalid("隐私预览不可用"))
}
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError>;
@@ -117,6 +124,21 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
#[async_trait]
impl SettingsService for DefaultSettingsService {
async fn preview_privacy_policy(
&self,
request: gateway_core::settings::privacy::PrivacyPreviewRequest,
) -> Result<gateway_core::settings::privacy::PrivacyPreviewResult, AdminError> {
self.profile_provider("openai")?
.preview_privacy_policy(request)
.map_err(|error| {
AdminError::invalid(format!(
"第 {} 条规则:{}",
error.rule_index + 1,
error.reason
))
})
}
async fn preview_pricing_sync(
&self,
) -> Result<crate::model::pricing::PricingSyncPreview, AdminError> {
@@ -342,6 +364,17 @@ impl SettingsService for DefaultSettingsService {
mut command: ReplaceRuntimeSettings,
) -> Result<RuntimeSettings, AdminError> {
validate_settings(&command)?;
if command.values.codex_privacy_policy != Default::default() {
self.profile_provider("openai")?
.compile_privacy_policy(&command.values.codex_privacy_policy)
.map_err(|error| {
AdminError::invalid(format!(
"第 {} 条规则:{}",
error.rule_index + 1,
error.reason
))
})?;
}
if command
.request_profile_updates
.values()
@@ -1115,6 +1115,7 @@ impl SettingsStore for StaticSettingsStore {
rotation_strategy: RotationStrategy::Smart,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 300,
@@ -32,6 +32,7 @@ fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> Runti
rotation_strategy: gateway_admin::model::settings::RotationStrategy::Smart,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 300,
@@ -988,6 +988,7 @@ impl SettingsStore for FixtureSettingsStore {
rotation_strategy: RotationStrategy::Smart,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 300,
@@ -57,6 +57,7 @@ impl VisitMut for WireTypes {
let last = path.path.segments.last().unwrap().clone();
*ty = match last.ident.to_string().as_str() {
"DateTime" | "Tz" | "TokenPrice" | "AdminApiKey" => parse_quote!(String),
"Value" => parse_quote!(serde_json::Value),
"OpaqueProviderData" => parse_quote!(serde_json::Map<String, serde_json::Value>),
_ => Type::Path(syn::TypePath {
attrs: std::mem::take(&mut path.attrs),
@@ -79,6 +80,10 @@ fn contract_type(mut item: Item, name: &str) -> Item {
| "ReplaceRuntimeSettings"
| "AdminApiKeyMutation"
| "RegeneratedAdminApiKey"
| "CodexPrivacyPolicy"
| "PrivacyRule"
| "PrivacyPreviewRequest"
| "PrivacyPreviewResult"
);
(
&mut item.attrs,
@@ -107,6 +112,13 @@ fn contract_type(mut item: Item, name: &str) -> Item {
field.vis = parse_quote!(pub);
}
}
if let Item::Enum(item) = &mut item {
for variant in &mut item.variants {
variant
.attrs
.retain(|attr| !attr.path().is_ident("default"));
}
}
WireTypes.visit_item_mut(&mut item);
item
}
@@ -117,6 +129,19 @@ fn sdk_settings_contract_matches_host_declarations() {
let mut types = Vec::new();
let mut replacement_fields = Vec::new();
for (path, names) in [
(
"../gateway-core/src/settings/privacy.rs",
&[
"CodexPrivacyPolicy",
"PrivacyFailureMode",
"PrivacyScope",
"PrivacyAction",
"PrivacyRule",
"PrivacyRuleOutcome",
"PrivacyPreviewRequest",
"PrivacyPreviewResult",
][..],
),
(
"src/model/settings.rs",
&[
@@ -280,6 +280,7 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
model_mappings: Default::default(),
rotation_strategy: RotationStrategy::Smart,
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 0,
@@ -36,6 +36,7 @@ pub type ProviderRequestProfileUpdates =
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSettingsFields {
pub codex_privacy_policy: gateway_core::settings::privacy::CodexPrivacyPolicy,
pub request_location_enabled: bool,
pub request_location: gateway_core::account::RequestLocation,
pub refresh_margin_seconds: u64,
@@ -283,6 +284,10 @@ where
"/api/admin/settings/client-profiles/{provider}/preview",
post(preview_client_profile::<S>),
)
.route(
"/api/admin/settings/privacy/preview",
post(preview_privacy::<S>),
)
.route("/api/admin/settings/update", post(update_settings::<S>))
.route(
"/api/admin/settings/client-downloads/codex-desktop/windows",
@@ -719,6 +724,7 @@ impl RuntimeSettingsFields {
self,
) -> Result<gateway_admin::model::settings::RuntimeSettingsValues, WireValidationError> {
let values = gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: self.codex_privacy_policy,
request_location_enabled: self.request_location_enabled,
request_location: self
.request_location
@@ -767,6 +773,7 @@ impl RuntimeSettingsFields {
impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSettingsFields {
fn from(settings: gateway_admin::model::settings::RuntimeSettingsValues) -> Self {
crate::admin::settings::RuntimeSettingsFields {
codex_privacy_policy: settings.codex_privacy_policy,
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
refresh_margin_seconds: settings.refresh_margin_seconds,
@@ -837,3 +844,23 @@ fn settings_validation_error(field: &'static str) -> WireValidationError {
_ => "settings",
})
}
async fn preview_privacy<S>(
State(state): State<S>,
_auth: AdminAuth,
AdminJson(request): AdminJson<gateway_core::settings::privacy::PrivacyPreviewRequest>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let result = state
.admin_services()
.settings()
.preview_privacy_policy(request)
.await
.map_err(map_service_error)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result),
))
}
@@ -114,7 +114,6 @@ struct SystemUpdateDetailView {
notes: Option<String>,
cached: bool,
update_supported: bool,
restart_confirmation_supported: bool,
unsupported_reason: Option<String>,
warning: Option<String>,
}
@@ -134,7 +133,6 @@ impl From<SystemUpdateDetail> for SystemUpdateDetailView {
notes: detail.notes,
cached: detail.cached,
update_supported: detail.update_supported,
restart_confirmation_supported: true,
unsupported_reason: detail.unsupported_reason,
warning: detail.warning,
}
@@ -62,7 +62,7 @@ where
let started = match service
.start_prepared_provider_endpoint(
prepared, operation,
// 图像/搜索等原生端点没有对应的上游模型权限。
// 原生端点的模型语义由 Provider 从正文提取并检查账号政策
None, client_ip, user_agent, endpoint,
)
.await
@@ -600,6 +600,7 @@ impl SettingsStore for MemorySettingsStore {
rotation_strategy: command.rotation_strategy,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: command.values.codex_privacy_policy,
request_location_enabled: command.values.request_location_enabled,
request_location: command.values.request_location,
refresh_margin_seconds: command.values.refresh_margin_seconds,
@@ -1667,6 +1668,7 @@ fn test_runtime_settings() -> RuntimeSettings {
rotation_strategy: RotationStrategy::Smart,
updated_at: Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 3_600,
@@ -40,6 +40,7 @@ async fn response_json(response: axum::response::Response) -> Value {
fn update_body() -> Value {
json!({
"configRevision": 7,
"codexPrivacyPolicy": {"enabled":false,"onError":"skip_rule","rules":[]},
"requestLocationEnabled": false,
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
"modelMappings": {
@@ -78,6 +79,63 @@ fn update_body() -> Value {
})
}
#[tokio::test]
async fn privacy_save_requires_provider_validation_and_preview_requires_admin_auth() {
let fixture = AdminTestFixture::new().await;
fixture.auth.insert_session("valid-session");
let mut body = update_body();
let policy = json!({"enabled":true,"onError":"reject_request","rules":[{
"id":"auth","name":"自定义认证头","enabled":true,"scope":"request_header",
"selector":"authorization","action":"remove_field","pattern":null,
"replacement":"","value":null,"replaceAll":true,"caseInsensitive":false,"multiLine":false
}]});
body["codexPrivacyPolicy"] = policy.clone();
let response = app(fixture.state())
.oneshot(request(
Method::POST,
"/api/admin/settings/update",
Some(body),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let response = app(fixture.state())
.oneshot(request(Method::GET, "/api/admin/settings", None))
.await
.unwrap();
assert_eq!(
response_json(response).await["data"]["codexPrivacyPolicy"],
json!({"enabled":false,"onError":"skip_rule","rules":[]})
);
let response = app(fixture.state())
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/admin/settings/privacy/preview")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
json!({"policy":policy,"body":{},"headers":{},"turnMetadata":null}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn privacy_policy_is_required_and_rejects_unknown_contract_fields() {
let mut missing = update_body();
missing
.as_object_mut()
.unwrap()
.remove("codexPrivacyPolicy");
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(missing).is_err());
let mut unknown = update_body();
unknown["codexPrivacyPolicy"]["protectedFields"] = json!([]);
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(unknown).is_err());
}
#[tokio::test]
async fn smart_settings_round_trip_and_invalid_updates_leave_the_saved_value_intact() {
let fixture = AdminTestFixture::new().await;
@@ -223,6 +281,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
.single()
.expect("timestamp"),
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: 1800,
@@ -268,6 +327,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
"providerRequestProfiles": {},
"openaiClientProfile": null,
"xaiClientProfile": null,
"codexPrivacyPolicy": {"enabled":false,"onError":"skip_rule","rules":[]},
"requestLocationEnabled": false,
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
"modelMappings": {
@@ -348,6 +408,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
.expect("fixture rotation strategy"),
updated_at: chrono::Utc::now(),
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds: request.values.refresh_margin_seconds,
@@ -1045,6 +1045,7 @@ where
.with_fast_mode(self.plan.fast_mode())
.with_pricing(self.plan.pricing())
.with_request_location(self.plan.request_location().cloned())
.with_privacy(self.plan.privacy())
.with_concurrency_wait_budget(self.concurrency_wait_budget.clone())
.with_connection_budget(self.connection_budget.clone())
.with_timing_started_at(self.observation.timing_started_at)
@@ -1510,6 +1511,8 @@ where
error.pre_delivery_retry(),
Some(crate::error::PreDeliveryRetry::SameAccountConnectionRetry { .. })
);
// Provider 的有界同账号传输恢复是尽力重试策略,不要求把 Sent/Ambiguous
// 改写成安全证明;外部副作用、取消、预算与下游提交仍由 Core 拦截
let transport_recovery = match error.pre_delivery_retry() {
Some(crate::error::PreDeliveryRetry::SameAccountConnectionRetry { transport })
if !execution_effect_observed
@@ -1533,8 +1536,7 @@ where
}) if !execution_effect_observed
&& !self.connection_budget.exhausted()
&& self.downstream_committed_at.is_none()
&& !self.delivery_pending
&& attempt_send_state != UpstreamSendState::Ambiguous =>
&& !self.delivery_pending =>
{
Some((AttemptTransport::Retry(retry_index), delay))
}
@@ -1542,8 +1544,7 @@ where
if !execution_effect_observed
&& !self.connection_budget.exhausted()
&& self.downstream_committed_at.is_none()
&& !self.delivery_pending
&& attempt_send_state != UpstreamSendState::Ambiguous =>
&& !self.delivery_pending =>
{
Some((
AttemptTransport::Fallback,
@@ -287,6 +287,7 @@ pub struct RequestAttemptContext {
request_profile: Option<crate::account::OpaqueProviderData>,
fast_mode: FastMode,
request_location: Option<crate::account::RequestLocation>,
privacy: Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>>,
request_id: ModelRequestId,
client_api_key_ref: ClientApiKeyId,
timing_started_at: Instant,
@@ -350,6 +351,15 @@ impl RequestAttemptContext {
self
}
#[must_use]
pub fn with_privacy(
mut self,
privacy: Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>>,
) -> Self {
self.privacy = privacy;
self
}
#[must_use]
pub fn with_request_location(
mut self,
@@ -369,6 +379,7 @@ impl RequestAttemptContext {
pricing: Arc::default(),
fast_mode: FastMode::Default,
request_location: None,
privacy: None,
timing_started_at: Instant::now(),
trace: crate::diagnostics::TraceContext::default(),
concurrency_wait_budget: crate::concurrency::ConcurrencyWaitBudget::default(),
@@ -569,6 +580,11 @@ impl AttemptContext {
self.request.fast_mode
}
#[must_use]
pub fn privacy(&self) -> Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>> {
self.request.privacy.clone()
}
#[must_use]
pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> {
self.request.request_location.as_ref()
@@ -965,6 +965,20 @@ impl fmt::Debug for ProviderRequest {
/// 新的 attempt 再次调用
#[async_trait]
pub trait Provider: Send + Sync {
/// Provider 解释自己的隐私规则,Core 仅冻结编译结果
fn compile_privacy_policy(
&self,
_policy: &crate::settings::privacy::CodexPrivacyPolicy,
) -> Result<
Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>,
crate::settings::privacy::PrivacyError,
> {
Err(crate::settings::privacy::PrivacyError {
rule_index: 0,
reason: "Provider 不支持隐私策略",
})
}
/// 从已冻结的配置解析请求身份;只读取本地发布资料,不执行网络请求
fn resolve_request_profile(
&self,
@@ -1197,3 +1211,31 @@ impl ProviderCatalogPort for ProviderRegistry {
})
}
}
impl std::fmt::Debug for ProviderRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderRegistry")
.field("providers", &self.providers.keys())
.finish()
}
}
impl crate::settings::privacy::PrivacyPolicyCompiler for ProviderRegistry {
fn compile(
&self,
policy: &crate::settings::privacy::CodexPrivacyPolicy,
) -> Result<
Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>,
crate::settings::privacy::PrivacyError,
> {
self.providers
.iter()
.find(|(kind, _)| kind.as_str() == "openai")
.ok_or(crate::settings::privacy::PrivacyError {
rule_index: 0,
reason: "OpenAI Provider 不可用",
})?
.1
.compile_privacy_policy(policy)
}
}
+4 -2
View File
@@ -135,6 +135,7 @@ pub enum PreDeliveryRetry {
max_delay: Duration,
},
/// 固定本次账号,并按 Provider 给出的序号重试当前传输
/// 可用于发送结果不明确时的有界尽力恢复,不等于上游未执行的证明
SameAccountTransportRetry {
/// Provider-owned 传输重试序号,从 1 开始
retry_index: NonZeroU32,
@@ -630,8 +631,8 @@ impl ProviderError {
/// 允许 Core 在首个客户端事件前固定原账号并改用 Provider 备用传输
///
/// 与普通换号恢复相同,Core 仍会拒绝 continuation、指定账号、预算耗尽或
/// 下游已提交后的隐藏重放
/// Provider 必须确认备用传输满足当前 continuation scope 并限制恢复次数
/// Core 保持原账号,拒绝预算耗尽、外部副作用或下游已提交后的隐藏重放
#[must_use]
pub fn with_pre_delivery_transport_fallback(mut self) -> Self {
self.set_pre_delivery_transport_fallback();
@@ -647,6 +648,7 @@ impl ProviderError {
///
/// 重试预算和退避策略由 Provider 所属协议定义;Core 只负责同账号钉选、
/// deadline/取消边界以及下游 commit barrier
/// 该意图允许发送结果不明确时尽力恢复,但不修改发送事实或安全重放证明
#[must_use]
pub fn with_pre_delivery_transport_retry(
mut self,
+2 -1
View File
@@ -472,7 +472,8 @@ pub fn prepare_control_plane(
providers: ProviderRegistry,
extensions: Option<Arc<dyn routing::extensions::ExtensionPreparationPort>>,
) -> CoreControlPlaneStartup {
let mut compiler = RuntimeSnapshotCompiler::new(ports.snapshots, Arc::new(providers));
let mut compiler = RuntimeSnapshotCompiler::new(ports.snapshots, Arc::new(providers.clone()))
.with_privacy_compiler(Arc::new(providers));
if let Some(extensions) = extensions {
compiler = compiler.with_extensions(extensions);
}
@@ -563,6 +563,7 @@ impl ProviderCandidate {
pub struct RoutingPlan {
pricing: Arc<crate::metering::PricingOverrides>,
request_location: Option<crate::account::RequestLocation>,
privacy: Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>>,
config_revision: ConfigRevision,
account_selection_policy: AccountSelectionPolicy,
operation: OperationKind,
@@ -582,6 +583,12 @@ impl RoutingPlan {
self.account_scope.fast_mode()
}
/// 本次请求冻结的已编译规则,重试不读取新的全局策略
#[must_use]
pub fn privacy(&self) -> Option<Arc<dyn crate::settings::privacy::CompiledPrivacyPolicy>> {
self.privacy.clone()
}
/// 本次请求冻结的全局位置,重试时沿用同一份配置
#[must_use]
pub const fn request_location(&self) -> Option<&crate::account::RequestLocation> {
@@ -219,6 +219,7 @@ pub enum RuntimeSnapshotCompileError {
#[derive(Clone)]
pub struct RuntimeSnapshotCompiler {
store: Arc<dyn SnapshotStorePort>,
privacy_compiler: Option<Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>>,
catalogs: Arc<dyn ProviderCatalogPort>,
extensions: Option<Arc<dyn crate::routing::extensions::ExtensionPreparationPort>>,
}
@@ -231,6 +232,7 @@ impl RuntimeSnapshotCompiler {
) -> Self {
Self {
store,
privacy_compiler: None,
catalogs,
extensions: None,
}
@@ -245,6 +247,15 @@ impl RuntimeSnapshotCompiler {
self
}
#[must_use]
pub fn with_privacy_compiler(
mut self,
compiler: Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>,
) -> Self {
self.privacy_compiler = Some(compiler);
self
}
pub(crate) fn store(&self) -> Arc<dyn SnapshotStorePort> {
Arc::clone(&self.store)
}
@@ -314,8 +325,14 @@ impl RuntimeSnapshotCompiler {
let cached = previous.filter(|previous| {
previous.extensions().map(|set| set.id()) == extensions.as_ref().map(|set| set.id())
});
let snapshot =
compile_runtime_snapshot(facts, catalogs.as_ref(), provider_kinds, cached).await?;
let snapshot = compile_runtime_snapshot(
facts,
catalogs.as_ref(),
provider_kinds,
cached,
self.privacy_compiler.clone(),
)
.await?;
let observed_generations = catalogs.catalog_generations();
if catalog_generations == observed_generations {
if extensions.is_some()
@@ -348,6 +365,7 @@ async fn compile_runtime_snapshot(
catalogs: &dyn ProviderCatalogPort,
provider_kinds: Vec<ProviderKind>,
previous: Option<&RuntimeSnapshot>,
privacy_compiler: Option<Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>>,
) -> Result<RuntimeSnapshot, RuntimeSnapshotCompileError> {
// 只有成功取得的完整目录能证明模型缺项;发现型目录和查询失败均交由上游验证
let mut provider_models = Vec::new();
@@ -504,12 +522,13 @@ async fn compile_runtime_snapshot(
));
}
RuntimeSnapshot::new(
RuntimeSnapshot::new_with_privacy_compiler(
facts.config_revision,
facts.settings,
provider_kinds,
provider_models,
client_policies,
privacy_compiler,
)
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)
.map(|snapshot| {
@@ -524,6 +543,7 @@ async fn compile_runtime_snapshot(
#[derive(Debug, Clone)]
pub struct RuntimeSnapshot {
settings: Arc<CompiledSettings>,
privacy_compiler: Option<Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>>,
extensions: Option<crate::routing::extensions::ExtensionSetReference>,
revision: ConfigRevision,
providers: Arc<BTreeSet<ProviderKind>>,
@@ -559,8 +579,12 @@ impl RuntimeSnapshot {
&self,
values: &SettingsValues,
) -> Result<Self, RuntimeSnapshotCompileError> {
let settings = CompiledSettings::new(values.clone())
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)?;
let settings = CompiledSettings::new(
values.clone(),
self.privacy_compiler.as_deref(),
Some(&self.settings),
)
.map_err(|_| RuntimeSnapshotCompileError::InvalidData)?;
Ok(Self {
settings: Arc::new(settings),
..self.clone()
@@ -649,6 +673,24 @@ impl RuntimeSnapshot {
providers: Vec<ProviderKind>,
provider_models: Vec<ProviderModel>,
client_policies: Vec<ClientPolicy>,
) -> Result<Self, RoutingError> {
Self::new_with_privacy_compiler(
revision,
values,
providers,
provider_models,
client_policies,
None,
)
}
pub fn new_with_privacy_compiler(
revision: ConfigRevision,
values: SettingsValues,
providers: Vec<ProviderKind>,
provider_models: Vec<ProviderModel>,
client_policies: Vec<ClientPolicy>,
privacy_compiler: Option<Arc<dyn crate::settings::privacy::PrivacyPolicyCompiler>>,
) -> Result<Self, RoutingError> {
let mut provider_set = BTreeSet::new();
for provider in providers {
@@ -709,9 +751,11 @@ impl RuntimeSnapshot {
}
client_policy_map.retain(|_, policy| policy.enabled());
let settings = CompiledSettings::new(values).map_err(|_| RoutingError::InvalidSettings)?;
let settings = CompiledSettings::new(values, privacy_compiler.as_deref(), None)
.map_err(|_| RoutingError::InvalidSettings)?;
Ok(Self {
settings: Arc::new(settings),
privacy_compiler,
extensions: None,
revision,
providers: Arc::new(provider_set),
@@ -1143,6 +1187,7 @@ impl RuntimeSnapshot {
config_revision: self.revision,
pricing: Arc::clone(&self.settings.values.pricing),
request_location: self.settings.request_location.clone(),
privacy: self.settings.privacy.clone(),
account_selection_policy: self.settings.account_selection_policy,
operation: operation.kind(),
max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS)
@@ -1223,6 +1268,7 @@ impl RuntimeSnapshot {
config_revision: self.revision,
pricing: Arc::clone(&self.settings.values.pricing),
request_location: self.settings.request_location.clone(),
privacy: self.settings.privacy.clone(),
account_selection_policy: self.settings.account_selection_policy,
operation: operation.kind(),
max_attempts: NonZeroU32::new(super::MAX_REQUEST_ATTEMPTS)
@@ -13,6 +13,7 @@ use crate::{
#[derive(Debug, Clone)]
pub(crate) struct CompiledSettings {
pub(crate) values: SettingsValues,
pub(crate) privacy: Option<std::sync::Arc<dyn super::privacy::CompiledPrivacyPolicy>>,
pub(crate) account_selection_policy: AccountSelectionPolicy,
pub(crate) client_queue_policy: ConcurrencyQueuePolicy,
pub(crate) responses_max_decompressed_body_bytes: std::num::NonZeroUsize,
@@ -21,7 +22,34 @@ pub(crate) struct CompiledSettings {
}
impl CompiledSettings {
pub(crate) fn new(settings: SettingsValues) -> Result<Self, InvalidSettings> {
pub(crate) fn new(
settings: SettingsValues,
compiler: Option<&dyn super::privacy::PrivacyPolicyCompiler>,
previous: Option<&Self>,
) -> Result<Self, InvalidSettings> {
let privacy = if let Some(previous) = previous.filter(|previous| {
previous.values.codex_privacy_policy == settings.codex_privacy_policy
}) {
previous.privacy.clone()
} else if settings.codex_privacy_policy == super::privacy::CodexPrivacyPolicy::default() {
None
} else {
Some(
compiler
.ok_or(InvalidSettings)?
.compile(&settings.codex_privacy_policy)
.map_err(|_| InvalidSettings)?,
)
};
// 关闭或没有启用规则时不进入出站管线,保留原始 JSON 字节和重复键语义
let privacy = privacy.filter(|_| {
settings.codex_privacy_policy.enabled
&& settings
.codex_privacy_policy
.rules
.iter()
.any(|rule| rule.enabled)
});
super::validate_request_limits(
settings.max_waiting_per_key,
settings.max_waiting_per_account,
@@ -79,6 +107,7 @@ impl CompiledSettings {
min_codex_client_versions,
request_location,
values: settings,
privacy,
})
}
}
@@ -1,6 +1,7 @@
//! 运行设置的纯值、校验与编译结果
pub(crate) mod compiled;
pub mod privacy;
mod values;
pub use values::{
SettingsValues, client_min_versions, response_body_limit, validate_request_limits,
@@ -0,0 +1,134 @@
//! 隐私策略配置、编译端口与无敏感值的执行结果,协议解析由 Provider 拥有
use std::{fmt, sync::Arc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CodexPrivacyPolicy {
pub enabled: bool,
pub on_error: PrivacyFailureMode,
pub rules: Vec<PrivacyRule>,
}
impl fmt::Debug for CodexPrivacyPolicy {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CodexPrivacyPolicy")
.field("enabled", &self.enabled)
.field("on_error", &self.on_error)
.field("rule_count", &self.rules.len())
.finish()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrivacyFailureMode {
#[default]
SkipRule,
RejectRequest,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrivacyScope {
TurnMetadata,
DesktopGitContext,
EnvironmentText,
RequestBody,
RequestHeader,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrivacyAction {
RegexReplace,
SetValue,
RenameKey,
RemoveField,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PrivacyRule {
pub id: String,
pub name: String,
pub enabled: bool,
pub scope: PrivacyScope,
pub selector: String,
pub action: PrivacyAction,
/// 删除动作的 null 表示无条件删除,字符串模式只匹配目标字符串值
pub pattern: Option<String>,
pub replacement: String,
pub value: Value,
pub replace_all: bool,
pub case_insensitive: bool,
pub multi_line: bool,
}
impl fmt::Debug for PrivacyRule {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PrivacyRule")
.field("scope", &self.scope)
.field("action", &self.action)
.field("enabled", &self.enabled)
.finish_non_exhaustive()
}
}
/// 编译和执行失败仅携带规则序号与静态原因,不回显表达式或请求内容
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize)]
#[serde(rename_all = "camelCase")]
#[error("privacy rule {rule_index}: {reason}")]
pub struct PrivacyError {
pub rule_index: usize,
pub reason: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrivacyRuleOutcome {
pub rule_id: String,
pub matches: usize,
pub status: String,
pub reason: Option<String>,
}
/// 已编译策略随快照冻结,调用方提供独立的本次出站副本
pub trait CompiledPrivacyPolicy: fmt::Debug + Send + Sync {
fn apply(
&self,
body: &mut Value,
headers: &mut http::HeaderMap,
turn_metadata: &mut Option<String>,
cancelled: &dyn Fn() -> bool,
) -> Result<Vec<PrivacyRuleOutcome>, PrivacyError>;
}
pub trait PrivacyPolicyCompiler: fmt::Debug + Send + Sync {
fn compile(
&self,
policy: &CodexPrivacyPolicy,
) -> Result<Arc<dyn CompiledPrivacyPolicy>, PrivacyError>;
}
/// 预览只接受管理员主动提供的受限样本,不读取实际请求记录
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PrivacyPreviewRequest {
pub policy: CodexPrivacyPolicy,
pub body: Value,
pub headers: std::collections::BTreeMap<String, Vec<String>>,
pub turn_metadata: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrivacyPreviewResult {
pub body: Value,
pub headers: std::collections::BTreeMap<String, Vec<String>>,
pub turn_metadata: Option<String>,
pub outcomes: Vec<PrivacyRuleOutcome>,
}
@@ -10,6 +10,7 @@ use crate::identity::ProviderKind;
pub struct SettingsValues {
pub(crate) pricing: Arc<crate::metering::PricingOverrides>,
pub(crate) request_profiles: Arc<BTreeMap<ProviderKind, crate::account::OpaqueProviderData>>,
pub(crate) codex_privacy_policy: super::privacy::CodexPrivacyPolicy,
pub(crate) request_location_enabled: bool,
pub(crate) request_location: crate::account::RequestLocation,
pub(crate) max_concurrent_per_account: u32,
@@ -30,6 +31,12 @@ pub struct SettingsValues {
}
impl SettingsValues {
#[must_use]
pub fn with_codex_privacy_policy(mut self, policy: super::privacy::CodexPrivacyPolicy) -> Self {
self.codex_privacy_policy = policy;
self
}
#[must_use]
pub const fn with_openai_session_affinity_ttl_hours(mut self, hours: u32) -> Self {
self.openai_session_affinity_ttl_hours = hours;
@@ -131,6 +138,7 @@ impl SettingsValues {
Self {
request_profiles: Arc::default(),
pricing: Arc::default(),
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: crate::account::RequestLocation::default(),
max_concurrent_per_account,
@@ -3493,7 +3493,7 @@ fn ambiguous_pre_delivery_retry_marker_does_not_rotate_account() {
}
#[test]
fn ambiguous_transport_retry_is_rejected_even_with_provider_session_state() {
fn ambiguous_transport_retry_keeps_the_account_without_claiming_replay_safety() {
let operation = generate_operation();
let route_plan = plan(&operation);
let checkpoint = ProviderSessionState::new(
@@ -3513,7 +3513,6 @@ fn ambiguous_transport_retry_is_rejected_even_with_provider_session_state() {
Ok(checkpoint_event),
Err(
ProviderError::new(ProviderErrorKind::Transport, UpstreamSendState::Ambiguous)
.with_replay_safe()
.with_pre_delivery_transport_retry(retry_index, Duration::ZERO),
),
],
@@ -3533,14 +3532,25 @@ fn ambiguous_transport_retry_is_rejected_even_with_provider_session_state() {
CancellationToken::new(),
))
.expect("start execution");
let error =
block_on(session.collect_uncommitted()).expect_err("ambiguous send cannot be replayed");
assert!(matches!(error, EngineError::Provider(_)));
assert_eq!(provider.contexts.lock().expect("contexts lock").len(), 1);
block_on(session.collect_uncommitted()).expect("bounded transport recovery succeeds");
block_on(session.commit_downstream(Some(200))).expect("commit winning response");
let contexts = provider.contexts.lock().expect("contexts lock");
assert_eq!(contexts.len(), 2);
assert_eq!(
contexts[1].transport(),
AttemptTransport::Retry(retry_index)
);
assert_eq!(
contexts[1].required_account(),
Some(&ProviderAccountId::new("acct_first").unwrap())
);
assert!(contexts[1].excluded_accounts().is_empty());
let state = store.state.lock().expect("store lock");
assert_eq!(state.intermediate_failures, 0);
assert_eq!(state.finalizations[0].attempt_count, 1);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Failed);
assert_eq!(state.intermediate_failures, 1);
assert_eq!(state.finalizations[0].attempt_count, 2);
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
assert!(state.send_states.contains(&UpstreamSendState::Ambiguous));
assert_eq!(state.finalizations[0].send_state, UpstreamSendState::Sent);
}
#[test]
@@ -18,6 +18,7 @@ use gateway_core::routing::{
UpstreamModelId,
};
mod privacy;
mod request_settings;
mod snapshot;
@@ -0,0 +1,125 @@
//! 隐私策略在快照与请求计划中的冻结、复用及关闭行为
use super::*;
use gateway_core::settings::privacy::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Default)]
struct Compiler(AtomicUsize);
#[derive(Debug)]
struct Compiled(serde_json::Value);
impl PrivacyPolicyCompiler for Compiler {
fn compile(
&self,
policy: &CodexPrivacyPolicy,
) -> Result<Arc<dyn CompiledPrivacyPolicy>, PrivacyError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(Arc::new(Compiled(policy.rules[0].value.clone())))
}
}
impl CompiledPrivacyPolicy for Compiled {
fn apply(
&self,
body: &mut serde_json::Value,
_: &mut http::HeaderMap,
_: &mut Option<String>,
_: &dyn Fn() -> bool,
) -> Result<Vec<PrivacyRuleOutcome>, PrivacyError> {
*body = self.0.clone();
Ok(Vec::new())
}
}
fn policy(value: &str) -> CodexPrivacyPolicy {
CodexPrivacyPolicy {
enabled: true,
on_error: PrivacyFailureMode::RejectRequest,
rules: vec![PrivacyRule {
id: "rule".into(),
name: "规则".into(),
enabled: true,
scope: PrivacyScope::RequestBody,
selector: "$.metadata".into(),
action: PrivacyAction::SetValue,
pattern: None,
replacement: String::new(),
value: serde_json::json!(value),
replace_all: false,
case_insensitive: false,
multi_line: false,
}],
}
}
fn compiled(snapshot: &RuntimeSnapshot) -> Option<Arc<dyn CompiledPrivacyPolicy>> {
snapshot
.plan_provider_endpoint(
&ProviderKind::new("openai").unwrap(),
None,
&image_operation(),
snapshot.all_account_scope(),
&RoutingContext::default(),
)
.unwrap()
.privacy()
}
#[test]
fn privacy_is_frozen_and_unrelated_request_settings_reuse_compiled_rules() {
let compiler = Arc::new(Compiler::default());
let snapshot = RuntimeSnapshot::new_with_privacy_compiler(
ConfigRevision::new(1).unwrap(),
settings().with_codex_privacy_policy(policy("first")),
vec![ProviderKind::new("openai").unwrap()],
vec![],
vec![],
Some(compiler.clone()),
)
.unwrap()
.with_account_directory(account_directory());
let original = compiled(&snapshot).unwrap();
let limits = snapshot
.with_settings(&snapshot.settings().clone().with_max_account_rotations(5))
.unwrap();
assert!(Arc::ptr_eq(&original, &compiled(&limits).unwrap()));
assert_eq!(compiler.0.load(Ordering::SeqCst), 1);
let changed = snapshot
.with_settings(
&snapshot
.settings()
.clone()
.with_codex_privacy_policy(policy("second")),
)
.unwrap();
let mut body = serde_json::Value::Null;
original
.apply(&mut body, &mut http::HeaderMap::new(), &mut None, &|| false)
.unwrap();
assert_eq!(body, "first");
compiled(&changed)
.unwrap()
.apply(&mut body, &mut http::HeaderMap::new(), &mut None, &|| false)
.unwrap();
assert_eq!(body, "second");
assert_eq!(compiler.0.load(Ordering::SeqCst), 2);
for disable_rule in [false, true] {
let mut policy = policy("unused");
if disable_rule {
policy.rules[0].enabled = false;
} else {
policy.enabled = false;
}
let disabled = snapshot
.with_settings(
&snapshot
.settings()
.clone()
.with_codex_privacy_policy(policy),
)
.unwrap();
assert!(compiled(&disabled).is_none());
}
}
@@ -1,20 +1 @@
[
{
"capability": "middleware",
"version": 3,
"replacement_version": 4,
"introduced_in": "3.20.0",
"last_counted_in": "3.22.2",
"remaining_releases": 0,
"migration": "将执行设置和来源中的 disable_fast 改为 fast_mode,使用 default、enabled、disabled 三态,并将 middleware 声明升级到 v4"
},
{
"capability": "upstream_adapter",
"version": 1,
"replacement_version": 2,
"introduced_in": "3.20.0",
"last_counted_in": "3.22.2",
"remaining_releases": 0,
"migration": "使用新版 SDK 读取 fast_mode 三态,并将 upstream_adapter 声明升级到 v2"
}
]
[]
@@ -8,8 +8,8 @@
{ "capability": "model_router", "versions": [1] },
{ "capability": "model_catalog", "versions": [1] },
{ "capability": "retry_policy", "versions": [1] },
{ "capability": "middleware", "versions": [3, 4] },
{ "capability": "upstream_adapter", "versions": [1, 2] },
{ "capability": "middleware", "versions": [4] },
{ "capability": "upstream_adapter", "versions": [2] },
{ "capability": "observer", "versions": [1] },
{ "capability": "command_line", "versions": [1] },
{ "capability": "management", "versions": [1] },
@@ -33,12 +33,7 @@ pub(super) async fn invoke(
.extensions()
.get::<core::Settings>()
.and_then(|settings| settings.runtime.as_ref())
.map(|settings| {
crate::compatibility::FastSettings::middleware(&ports.session).sources(settings)
})
.transpose()
.map_err(|_| MiddlewareError::InvalidState)?
.unwrap_or(serde_json::Value::Null);
.map_or(serde_json::Value::Null, |settings| settings.inspect());
let (invocation, request) = Invocation::new(request, next, entry.instance_id.clone())?;
let mut call = ports.session.context(Stage::Http, timeout);
call.request_id = Some(context.request_id.clone());
@@ -52,13 +52,7 @@ pub(super) async fn invoke(
if remaining.is_zero() || context.cancellation().is_cancelled() {
return Err(MiddlewareError::Fault);
}
let invocation = MiddlewareInvocation::new(
&context,
request,
next,
entry.instance_id.clone(),
crate::compatibility::FastSettings::middleware(&invocation_ports.session),
);
let invocation = MiddlewareInvocation::new(&context, request, next, entry.instance_id.clone());
let (protocol, headers, payload) = match invocation.request_parts() {
Ok(projection) => projection,
Err(_) => return recover_rpc(&entry, &invocation, RpcError::Context(None)).await,
@@ -96,9 +90,7 @@ pub(super) async fn invoke(
scope,
)?;
let head = MiddlewareRequestHead {
settings_sources: invocation
.settings_sources()
.map_err(|_| MiddlewareError::InvalidState)?,
settings_sources: invocation.settings_sources(),
settings: invocation
.settings()
.map_err(|_| MiddlewareError::InvalidState)?,
@@ -235,16 +235,15 @@ impl Execution {
.collect(),
continuation: previous.map(|state| state.continuation),
};
let payload = crate::compatibility::FastSettings::upstream_request(
&self.adapter.session,
request,
invocation
.operation
.middleware_body()
.map_err(|source| invalid().with_source(source))?
.to_vec(),
)
.map_err(|source| invalid().with_source(source))?;
let payload = request
.encode(
invocation
.operation
.middleware_body()
.map_err(|source| invalid().with_source(source))?
.to_vec(),
)
.map_err(|source| invalid().with_source(source))?;
invocation.context.trace().record("plugin.upstream", serde_json::json!({ "instanceId": self.adapter.instance_id, "adapterId": declaration.id, "generation": context.generation, "transport": declaration.transport.as_str() }));
let stream = tokio::select! {
biased;
@@ -86,7 +86,6 @@ fn request_feature(
pub(crate) struct MiddlewareInvocation {
instance_id: String,
settings_contract: crate::compatibility::FastSettings,
capabilities_allowed: bool,
transport: ClientTransport,
state: Mutex<InvocationState>,
@@ -141,28 +140,27 @@ impl MiddlewareInvocation {
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state
.original_request
.settings()
.map(|settings| self.settings_contract.execution(settings))
.unwrap_or(Ok(serde_json::Value::Null))
serde_json::to_value(
state
.original_request
.settings()
.and_then(|settings| settings.execution_values()),
)
.map_err(|_| invalid())
}
pub(crate) fn settings_sources(&self) -> Result<serde_json::Value, PluginFault> {
pub(crate) fn settings_sources(&self) -> serde_json::Value {
self.request_settings()
.map(|settings| self.settings_contract.sources(&settings))
.unwrap_or(Ok(serde_json::Value::Null))
.map_or(serde_json::Value::Null, |settings| settings.inspect())
}
pub(crate) fn new(
context: &MiddlewareContext,
request: MiddlewareRequest,
next: MiddlewareNext,
instance_id: String,
settings_contract: crate::compatibility::FastSettings,
) -> Arc<Self> {
Arc::new(Self {
instance_id,
settings_contract,
capabilities_allowed: context.mount()
== gateway_core::engine::middleware::MiddlewareMount::Request
&& context.operation() == Some(gateway_core::operation::OperationKind::Generate),
@@ -343,7 +341,7 @@ impl MiddlewareInvocation {
"execution settings are not available at this middleware boundary",
)
})?;
let values = self.settings_contract.decode(settings, current)?;
let values = serde_json::from_value(settings).map_err(|_| invalid())?;
let updated = current
.replace_execution(&values, &self.instance_id)
.map_err(|_| invalid())?;
@@ -1,8 +1,4 @@
//! 有期限的插件合同适配;旧字段只在此边界出现,不进入 Core 或持久化模型
mod fast;
pub(crate) use fast::FastSettings;
//! 插件合同弃用声明、管理端提示与加载日志
use std::sync::OnceLock;
@@ -26,7 +22,7 @@ struct Deprecation {
fn declarations() -> Result<&'static [Deprecation], AdminError> {
static DECLARATIONS: OnceLock<Result<Vec<Deprecation>, serde_json::Error>> = OnceLock::new();
DECLARATIONS
.get_or_init(|| serde_json::from_str(include_str!("../../plugin-api-deprecations.json")))
.get_or_init(|| serde_json::from_str(include_str!("../plugin-api-deprecations.json")))
.as_deref()
.map_err(|_| AdminError::internal("插件接口弃用声明不合法"))
}
@@ -1,109 +0,0 @@
//! 在旧插件合同边界投影 Fast 设置并保留宿主三态语义
use gateway_core::{
account::FastMode,
routing::request_settings::{ExecutionSettings, RequestSettings},
};
use gateway_plugin_sdk::{
Capability, ErrorCode, PluginFault,
call::{model::ExecutionEncodingError, upstream_adapter::UpstreamAdapterRequest},
};
use serde_json::Value;
use crate::RpcSession;
#[derive(Clone, Copy)]
pub(crate) struct FastSettings {
legacy: bool,
}
impl FastSettings {
pub(crate) fn middleware(session: &RpcSession) -> Self {
Self {
legacy: session.capability_version(Capability::Middleware) == Some(3),
}
}
pub(crate) fn execution(self, settings: &RequestSettings) -> Result<Value, PluginFault> {
let mut value = serde_json::to_value(settings.execution_values()).map_err(|_| invalid())?;
if self.legacy {
project_fast(&mut value)?;
}
Ok(value)
}
pub(crate) fn sources(self, settings: &RequestSettings) -> Result<Value, PluginFault> {
let mut value = settings.inspect();
if self.legacy
&& let Some(execution) = value["execution"].as_object_mut()
{
project_fast(execution.get_mut("input").ok_or_else(invalid)?)?;
let mut source = execution.remove("fast_mode").ok_or_else(invalid)?;
if !source.is_null() {
source["value"] = disabled(&source["value"])?;
}
execution.insert("disable_fast".into(), source);
}
Ok(value)
}
pub(crate) fn decode(
self,
mut value: Value,
current: &RequestSettings,
) -> Result<ExecutionSettings, PluginFault> {
if self.legacy {
let object = value.as_object_mut().ok_or_else(invalid)?;
if object.contains_key("fast_mode") {
return Err(invalid());
}
let next = object
.remove("disable_fast")
.and_then(|value| value.as_bool())
.ok_or_else(invalid)?;
let current = current.execution_values().ok_or_else(invalid)?.fast_mode;
// 原样回传 false 不能把三态的 enabled 降为 default,只有实际变化才覆盖
let mode = if next == (current == FastMode::Disabled) {
current
} else if next {
FastMode::Disabled
} else {
FastMode::Default
};
object.insert("fast_mode".into(), Value::String(mode.as_str().into()));
}
serde_json::from_value(value).map_err(|_| invalid())
}
pub(crate) fn upstream_request(
session: &RpcSession,
request: UpstreamAdapterRequest,
body: Vec<u8>,
) -> Result<Vec<u8>, ExecutionEncodingError> {
if session.capability_version(Capability::UpstreamAdapter) != Some(1) {
return request.encode(body);
}
let mut metadata = serde_json::to_value(request).map_err(|_| ExecutionEncodingError)?;
project_fast(&mut metadata).map_err(|_| ExecutionEncodingError)?;
gateway_plugin_sdk::call::upstream_adapter::encode_request_metadata(&metadata, body)
}
}
fn project_fast(value: &mut Value) -> Result<(), PluginFault> {
if value.is_null() {
return Ok(());
}
let object = value.as_object_mut().ok_or_else(invalid)?;
let mode = object.remove("fast_mode").ok_or_else(invalid)?;
object.insert("disable_fast".into(), disabled(&mode)?);
Ok(())
}
fn disabled(value: &Value) -> Result<Value, PluginFault> {
let mode: FastMode = serde_json::from_value(value.clone()).map_err(|_| invalid())?;
Ok(Value::Bool(mode == FastMode::Disabled))
}
fn invalid() -> PluginFault {
PluginFault::new(ErrorCode::InvalidInput, "invalid plugin execution settings")
}
@@ -205,16 +205,6 @@ impl RpcSession {
error
})
}
pub(crate) fn capability_version(
&self,
capability: gateway_plugin_sdk::Capability,
) -> Option<u32> {
self.handshake
.contributes
.get(&capability)
.map(|declaration| declaration.version)
}
}
pub(crate) struct Shared {
@@ -1,17 +1,17 @@
//! 验证旧中间件的 Fast 设置适配与不兼容插件的实例故障隔离
//! 验证中间件 Fast 三态设置与不兼容插件的实例故障隔离
use super::*;
use gateway_core::{policy::ClientPolicy, routing::request_settings::RequestSettings};
use serde_json::{Value, json};
#[tokio::test]
async fn legacy_middleware_preserves_three_state_fast_and_projects_sources() {
for (mode, write, expected) in [
(FastMode::Default, false, FastMode::Default),
(FastMode::Enabled, false, FastMode::Enabled),
(FastMode::Disabled, true, FastMode::Disabled),
(FastMode::Enabled, true, FastMode::Disabled),
(FastMode::Disabled, false, FastMode::Default),
async fn middleware_preserves_three_state_fast_and_sources() {
for (mode, expected) in [
(FastMode::Default, FastMode::Default),
(FastMode::Enabled, FastMode::Enabled),
(FastMode::Disabled, FastMode::Disabled),
(FastMode::Enabled, FastMode::Disabled),
(FastMode::Disabled, FastMode::Default),
] {
let snapshot = gateway_core::routing::RuntimeSnapshot::new(
ConfigRevision::new(1).unwrap(),
@@ -46,34 +46,40 @@ async fn legacy_middleware_preserves_three_state_fast_and_projects_sources() {
let mut seed = settings.execution_values().unwrap();
seed.fast_mode = mode;
let settings = settings.replace_execution(&seed, "seed").unwrap();
let mut wire = serde_json::to_value(&seed).unwrap();
wire.as_object_mut().unwrap().remove("fast_mode");
wire["disable_fast"] = json!(mode == FastMode::Disabled);
let wire = serde_json::to_value(&seed).unwrap();
let mut updated = wire.clone();
updated["disable_fast"] = json!(write);
updated["fast_mode"] = json!(expected.as_str());
updated["timeout_ms"] = json!(90_000);
let mut contribution = crate::support::contribution(
let contribution = crate::support::contribution(
Capability::Middleware,
vec![Stage::Request],
vec!["openai".into()],
vec!["openai".into()],
);
contribution.1.version = 3;
let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap();
let package = crate::support::package_with_contributions(
&worker,
Contributions::from([contribution]),
);
let (cache, runtime) = setup_package(vec![InstanceFixture {
id: "legacy-settings",
configuration: json!({
"mode":"settings", "middleware_version":3, "expected_settings":wire, "settings":updated,
"expected_legacy_source": if mode == FastMode::Default { Value::Null } else {
json!({"instance_id":"seed", "order":1, "value":mode == FastMode::Disabled})
},
}),
bindings: vec![binding(MIDDLEWARE_CONTRIBUTION,"request",0,PluginFailurePolicy::Reject)],
}], package).await;
let (cache, runtime) = setup_package(
vec![InstanceFixture {
id: "fast-settings",
configuration: json!({
"mode":"settings", "expected_settings":wire, "settings":updated,
"expected_fast_source": if mode == FastMode::Default { Value::Null } else {
json!({"instance_id":"seed", "order":1, "value":mode.as_str()})
},
}),
bindings: vec![binding(
MIDDLEWARE_CONTRIBUTION,
"request",
0,
PluginFailurePolicy::Reject,
)],
}],
package,
)
.await;
let generation = prepare(&runtime).await;
let plan = runtime
.execution_registry()
@@ -105,7 +111,7 @@ async fn legacy_middleware_preserves_three_state_fast_and_projects_sources() {
);
let sources = settings.inspect();
assert!(sources["execution"].get("disable_fast").is_none());
if mode == FastMode::Enabled && !write {
if mode != FastMode::Default && mode == expected {
assert_eq!(sources["execution"]["fast_mode"]["instance_id"], "seed");
}
next.run(request).await
@@ -102,18 +102,12 @@ async fn setup(
Arc<super::Store>,
gateway_plugin_runtime::PluginRuntime,
) {
let mut contributes = Contributions::from([crate::support::contribution(
let contributes = Contributions::from([crate::support::contribution(
Capability::UpstreamAdapter,
vec![Stage::Upstream],
vec!["openai".into()],
vec!["openai".into()],
)]);
if let Some(version) = config["upstream_version"].as_u64() {
contributes
.get_mut(&Capability::UpstreamAdapter)
.unwrap()
.version = version.try_into().unwrap();
}
let (cache, store, runtime) =
super::setup_with_contributions_and_restart_circuit(contributes, Default::default()).await;
{
@@ -226,7 +220,7 @@ fn execute_with_context(
}
#[tokio::test]
async fn legacy_adapter_decodes_exact_v1_metadata_for_all_fast_modes() {
async fn adapter_preserves_three_state_fast_in_request_metadata() {
use gateway_core::account::FastMode;
for mode in [FastMode::Default, FastMode::Enabled, FastMode::Disabled] {
let server = MockServer::start().await;
@@ -237,8 +231,7 @@ async fn legacy_adapter_decodes_exact_v1_metadata_for_all_fast_modes() {
.mount(&server)
.await;
let mut config = configuration(&server.uri(), false);
config["upstream_version"] = json!(1);
config["expected_disable_fast"] = json!(mode == FastMode::Disabled);
config["expected_fast_mode"] = json!(mode.as_str());
let (cache, _, runtime) = setup(config).await;
let generation =
ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
@@ -43,9 +43,9 @@ fn host_compatibility_requires_the_trusted_middleware_contract() {
.expect("compatibility JSON");
assert!(!compatibility.supports_capability("middleware", 1));
assert!(!compatibility.supports_capability("middleware", 2));
assert!(compatibility.supports_capability("middleware", 3));
assert!(!compatibility.supports_capability("middleware", 3));
assert!(compatibility.supports_capability("middleware", 4));
assert!(compatibility.supports_capability("upstream_adapter", 1));
assert!(!compatibility.supports_capability("upstream_adapter", 1));
assert!(compatibility.supports_capability("upstream_adapter", 2));
assert!(!compatibility.supports_capability("openai", 1));
}
@@ -81,7 +81,7 @@ async fn package_inspector_returns_static_requirements_without_starting_the_plug
}
#[tokio::test]
async fn deprecated_contracts_are_loadable_and_only_old_versions_receive_notices() {
async fn retired_contracts_remain_loadable_with_compatibility_warnings() {
for legacy in [false, true] {
let mut middleware = crate::support::contribution(
Capability::Middleware,
@@ -105,13 +105,19 @@ async fn deprecated_contracts_are_loadable_and_only_old_versions_receive_notices
);
let inspector =
PackageInspector::new(PackageLimits::default(), semver::Version::new(1, 0, 0));
let artifact = inspector.inspect(archive, None).await.unwrap();
let artifact = inspector.inspect(archive.clone(), None).await.unwrap();
let notices = inspector.api_deprecations(&artifact.metadata).unwrap();
assert_eq!(notices.len(), if legacy { 2 } else { 0 });
for notice in notices {
assert!(notice.replacement_version > notice.version);
assert!(!notice.migration.is_empty());
assert!(notice.remaining_releases <= 7);
assert!(notices.is_empty());
let warning = inspector
.compatibility_warning(archive, artifact.metadata.sha256)
.await
.unwrap();
if legacy {
let warning = warning.expect("retired contracts must remain visible");
assert!(warning.contains("middleware v3"));
assert!(warning.contains("upstream_adapter v1"));
} else {
assert!(warning.is_none());
}
}
}
@@ -489,12 +489,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
call.request.head.settings,
configuration["expected_settings"]
);
if let Some(expected) = configuration.get("expected_legacy_source") {
if let Some(expected) = configuration.get("expected_fast_source") {
let sources = &call.request.head.settings_sources["execution"];
assert!(sources.get("fast_mode").is_none());
assert!(sources["input"].get("fast_mode").is_none());
assert_eq!(sources["input"]["disable_fast"], false);
assert_eq!(&sources["disable_fast"], expected);
assert!(sources.get("disable_fast").is_none());
assert!(sources["input"].get("disable_fast").is_none());
assert_eq!(sources["input"]["fast_mode"], "default");
assert_eq!(&sources["fast_mode"], expected);
}
if let Some(settings) = configuration.get("settings") {
call.request.head.settings = settings.clone();
@@ -1,53 +0,0 @@
//! 为测试子进程严格解码上游适配器 v1 的请求元数据
use gateway_plugin_sdk::call::upstream_adapter::{
BuiltinProvider, UpstreamAdapterRequest, UpstreamContinuation,
};
use serde::Deserialize;
// 固定 v1 的严格解码器,不能用新版 DTO 的宽松解析掩盖旧插件加载失败
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Request {
adapter_id: String,
provider: BuiltinProvider,
upstream_model: String,
client_key_id: String,
account_id: String,
credential_revision: u64,
protocol: String,
client_transport: String,
disable_fast: bool,
headers: Vec<(String, Vec<u8>)>,
continuation: Option<UpstreamContinuation>,
}
pub fn decode(payload: &[u8], expected_disabled: bool) -> (UpstreamAdapterRequest, Vec<u8>) {
assert_eq!(&payload[..4], b"GPAQ");
let metadata_length = u32::from_be_bytes(payload[4..8].try_into().unwrap()) as usize;
let body_length = u32::from_be_bytes(payload[8..12].try_into().unwrap()) as usize;
assert_eq!(payload.len(), 12 + metadata_length + body_length);
let old: Request = serde_json::from_slice(&payload[12..12 + metadata_length]).unwrap();
assert_eq!(old.disable_fast, expected_disabled);
(
UpstreamAdapterRequest {
adapter_id: old.adapter_id,
provider: old.provider,
upstream_model: old.upstream_model,
client_key_id: old.client_key_id,
account_id: old.account_id,
credential_revision: old.credential_revision,
protocol: old.protocol,
client_transport: old.client_transport,
fast_mode: if old.disable_fast {
"disabled"
} else {
"default"
}
.into(),
headers: old.headers,
continuation: old.continuation,
},
payload[12 + metadata_length..].to_vec(),
)
}
@@ -1,7 +1,5 @@
//! 真实子进程协议对端;只依赖公开 SDK,Cargo 为集成测试构建此辅助二进制
mod legacy_upstream;
use std::{
collections::BTreeMap,
io::Write as _,
@@ -1481,16 +1479,10 @@ async fn main() {
impl Peer {
async fn upstream_adapter(&self, id: u64, payload: Vec<u8>) -> Result<(), PluginFault> {
let (request, body) = if self.configuration["upstream_version"] == 1 {
legacy_upstream::decode(
&payload,
self.configuration["expected_disable_fast"]
.as_bool()
.unwrap(),
)
} else {
UpstreamAdapterRequest::decode(&payload).unwrap()
};
let (request, body) = UpstreamAdapterRequest::decode(&payload).unwrap();
if let Some(expected) = self.configuration.get("expected_fast_mode") {
assert_eq!(request.fast_mode, expected.as_str().unwrap());
}
// 假凭据用于检测宿主是否把已选账号令牌放入了插件输入
assert!(!String::from_utf8_lossy(&payload).contains("fixture-native-token"));
self.append_observation_marker("upstream_marker", &json!({"key":request.client_key_id,"account":request.account_id,"continuation":request.continuation}));
@@ -42,6 +42,9 @@
当前支持范围由[宿主兼容声明](../../runtime/plugin-host-compatibility.json)给出,弃用状态、剩余兼容窗口及作者说明以[弃用清单](../../runtime/plugin-api-deprecations.json)为准,管理端和加载日志展示同一提示
执行设置与来源使用 `fast_mode` 的 `default`、`enabled`、`disabled` 三态,宿主不转换旧 `disable_fast` 字段。
使用 `middleware v3` 或 `upstream_adapter v1` 的插件需按当前 SDK 更新字段处理、升级能力版本并重新构建,仅修改版本声明不能替代迁移
兼容窗口自替代合同首次正式发布起至少覆盖 7 个后续正式版本;主版本、次版本和补丁版本各计一次,预发行与同标签重试不计。
窗口内提供适配,窗口外不保证兼容;是否使用过特定字段不改变判断,版本号差值也不能代替清单中的计数
@@ -16,8 +16,80 @@ pub type RotationStrategy = String;
pub type AccountAffinity = String;
pub type PricingOverrides = BTreeMap<String, BTreeMap<String, ModelPriceOverride>>;
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CodexPrivacyPolicy {
pub enabled: bool,
pub on_error: PrivacyFailureMode,
pub rules: Vec<PrivacyRule>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrivacyFailureMode {
SkipRule,
RejectRequest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrivacyScope {
TurnMetadata,
DesktopGitContext,
EnvironmentText,
RequestBody,
RequestHeader,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PrivacyAction {
RegexReplace,
SetValue,
RenameKey,
RemoveField,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PrivacyRule {
pub id: String,
pub name: String,
pub enabled: bool,
pub scope: PrivacyScope,
pub selector: String,
pub action: PrivacyAction,
/// 删除动作的 null 表示无条件删除,字符串模式只匹配目标字符串值
pub pattern: Option<String>,
pub replacement: String,
pub value: serde_json::Value,
pub replace_all: bool,
pub case_insensitive: bool,
pub multi_line: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrivacyRuleOutcome {
pub rule_id: String,
pub matches: usize,
pub status: String,
pub reason: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PrivacyPreviewRequest {
pub policy: CodexPrivacyPolicy,
pub body: serde_json::Value,
pub headers: BTreeMap<String, Vec<String>>,
pub turn_metadata: Option<String>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrivacyPreviewResult {
pub body: serde_json::Value,
pub headers: BTreeMap<String, Vec<String>>,
pub turn_metadata: Option<String>,
pub outcomes: Vec<PrivacyRuleOutcome>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeSettings {
pub codex_privacy_policy: CodexPrivacyPolicy,
pub request_location_enabled: bool,
pub request_location: RequestLocation,
pub refresh_margin_seconds: u64,
@@ -57,6 +129,7 @@ pub struct RuntimeSettings {
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReplaceRuntimeSettings {
pub codex_privacy_policy: CodexPrivacyPolicy,
pub request_location_enabled: bool,
pub request_location: RequestLocation,
pub refresh_margin_seconds: u64,
@@ -190,6 +263,12 @@ impl Operation for Load {
type Input = ();
type Output = RuntimeSettings;
}
pub struct PreviewPrivacyPolicy;
impl Operation for PreviewPrivacyPolicy {
const NAME: &'static str = "settings.preview_privacy_policy";
type Input = PrivacyPreviewRequest;
type Output = PrivacyPreviewResult;
}
pub struct Replace;
impl Operation for Replace {
const NAME: &'static str = "settings.replace";
@@ -253,6 +332,7 @@ impl Operation for PreviewClientProfile {
impl From<RuntimeSettings> for ReplaceRuntimeSettings {
fn from(settings: RuntimeSettings) -> Self {
Self {
codex_privacy_policy: settings.codex_privacy_policy,
request_location_enabled: settings.request_location_enabled,
request_location: settings.request_location,
refresh_margin_seconds: settings.refresh_margin_seconds,
@@ -39,15 +39,7 @@ pub(super) fn encode_request(
request: UpstreamAdapterRequest,
body: Vec<u8>,
) -> Result<Vec<u8>, ExecutionEncodingError> {
encode_request_metadata(&request, body)
}
/// 封装按合同版本投影的请求元数据,保持 GPAQ 分帧与载荷限制统一
pub fn encode_request_metadata(
metadata: &impl Serialize,
body: Vec<u8>,
) -> Result<Vec<u8>, ExecutionEncodingError> {
pack(*b"GPAQ", metadata, [body])
pack(*b"GPAQ", &request, [body])
}
pub(super) fn decode_request(
@@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize};
use super::model::ExecutionEvent;
mod codec;
pub use codec::encode_request_metadata;
pub const REGISTER_METHOD: &str = "upstream_adapter.register";
pub const EXECUTE_METHOD: &str = "upstream_adapter.execute";
@@ -26,7 +26,7 @@ impl Capability {
#[must_use]
pub const fn contract_versions(self) -> &'static [u32] {
match self {
Self::Middleware => &[3, 4],
Self::Middleware => &[4],
Self::FrontendAuthentication
| Self::Scheduler
| Self::ModelRouter
@@ -36,7 +36,7 @@ impl Capability {
| Self::CommandLine
| Self::Management
| Self::Maintenance => &[1],
Self::UpstreamAdapter => &[1, 2],
Self::UpstreamAdapter => &[2],
}
}
@@ -132,7 +132,7 @@ fn installation_checks_protocol_engine_and_target() {
fn capability_contract_versions_are_checked_independently_of_rpc_version() {
let mut manifest = packaged_manifest();
assert!(manifest.validate().is_ok());
for version in [0, 1, 2, 5, u32::MAX] {
for version in [0, 1, 2, 3, 5, u32::MAX] {
manifest
.contributes
.get_mut(&Capability::Middleware)
@@ -155,6 +155,29 @@ fn capability_contract_versions_are_checked_independently_of_rpc_version() {
assert_eq!(manifest.validate(), Err(ManifestError::Invalid));
}
#[test]
fn retired_upstream_contract_requires_an_author_upgrade() {
let mut manifest = source_manifest();
manifest.contributes.clear();
manifest.contributes.insert(
Capability::UpstreamAdapter,
ContributionDeclaration {
id: "9acme.request-tags.upstream".into(),
version: 2,
stages: vec![Stage::Upstream],
input_formats: vec!["openai".into()],
output_formats: vec!["openai".into()],
},
);
assert!(manifest.validate().is_ok());
manifest
.contributes
.get_mut(&Capability::UpstreamAdapter)
.unwrap()
.version = 1;
assert_eq!(manifest.validate(), Err(ManifestError::Invalid));
}
#[test]
fn package_rejects_file_directory_conflicts_and_missing_declared_files() {
let mut manifest = packaged_manifest();
@@ -96,6 +96,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
"provider_request_profiles_json".to_owned(),
"request_location_enabled".to_owned(),
"request_location_json".to_owned(),
"codex_privacy_policy_json".to_owned(),
"model_mappings_json".to_owned(),
"refresh_margin_seconds".to_owned(),
"refresh_concurrency".to_owned(),
@@ -10,7 +10,7 @@ pub(crate) async fn request_metrics(
filter.validate()?;
// 结果计数覆盖范围内全部请求(成功率/失败率的分母分子);用量、缓存、
// 延迟与成本聚合仅统计用量事实(已完整交付客户端的成功响应)
let fact = completed_usage_fact_predicate("mr");
let fact = "mr.is_completed_usage";
let mut query = QueryBuilder::<Postgres>::new(format!(
"select count(*)::bigint as request_count,
count(*) filter (where outcome = 'succeeded')::bigint as success_count,
@@ -87,14 +87,9 @@ pub(crate) async fn request_metrics(
round(percentile_cont(0.95) within group (
order by capacity_used_slots::double precision
/ nullif(capacity_total_slots, 0)
) * 10000)::bigint as capacity_utilization_p95_basis_points
from model_request_observations mr where mr.started_at >= "
) * 10000)::bigint as capacity_utilization_p95_basis_points "
));
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
push_request_metrics_source(&mut query, range, filter);
let row = query
.build()
.fetch_one(pool)
@@ -162,7 +157,7 @@ async fn request_metric_series_inner(
filter.validate()?;
// 与 request_metrics 同一契约:结果计数覆盖全部请求,用量/延迟/成本
// 聚合仅统计用量事实
let fact = completed_usage_fact_predicate("mr");
let fact = "mr.is_completed_usage";
let mut query = QueryBuilder::<Postgres>::new("select ");
push_metric_bucket(&mut query, range, granularity, timezone)?;
query.push(format!(
@@ -248,14 +243,9 @@ async fn request_metric_series_inner(
count(*) filter (where {fact} and cost_source = 'calculated')::bigint
as calculated_count,
count(*) filter (where {fact} and cost_source = 'unavailable')::bigint
as unavailable_count
from model_request_observations mr where mr.started_at >= "
as unavailable_count "
));
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(&mut query, "mr");
push_usage_filter(&mut query, filter, "mr");
push_request_metrics_source(&mut query, range, filter);
query.push(" group by bucket_start order by bucket_start");
let rows = query
.build()
@@ -289,6 +279,32 @@ async fn request_metric_series_inner(
fill_metric_gaps(range, granularity, points, timezone)
}
fn push_request_metrics_source(
query: &mut QueryBuilder<Postgres>,
range: ObservabilityRange,
filter: &UsageRecordFilter,
) {
let fact = completed_usage_fact_predicate("mr");
// 先筛选再投影计时与用量口径,供多个聚合复用;屏障只属于计时统计查询
// 显式保留聚合需要的列,避免宽 JSON、快照与诊断轨迹进入中间结果
query.push(format!(
"from (select mr.started_at, mr.outcome, mr.client_status_code,
mr.input_tokens, mr.output_tokens, mr.cached_tokens,
mr.cache_write_tokens, mr.reasoning_tokens, mr.total_tokens,
mr.first_token_ms, mr.latency_ms, mr.admission_decision_ms,
mr.account_selection_wait_ms, mr.upstream_response_ms,
mr.capacity_used_slots, mr.capacity_total_slots, mr.cost_source,
({fact}) as is_completed_usage
from model_request_observations mr where mr.started_at >= "
));
query.push_bind(range.start);
query.push(" and mr.started_at < ");
query.push_bind(range.end);
push_unrecovered_request_filter(query, "mr");
push_usage_filter(query, filter, "mr");
query.push(" offset 0) mr");
}
pub(crate) fn calculated_usage_billing_facts(
pool: &PgPool,
range: ObservabilityRange,
@@ -411,8 +411,9 @@ pub(crate) async fn usage_diagnostics(
push_unrecovered_request_filter(&mut statement, "mr");
push_diagnostic_dimension_filter(&mut statement, dimension);
push_usage_filter(&mut statement, filter, "mr");
// 仅在筛选后的诊断投影保留屏障,让均值与分位数复用同一次计时提取
statement.push(
"), aggregated as (
" offset 0), aggregated as (
select dimension_name, cost_currency,
grouping(cost_currency)::integer as currency_grouping,
count(*)::bigint as request_count,
@@ -150,7 +150,7 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository {
pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResult<RuntimeSettings> {
let row = sqlx::query_as::<_, RuntimeSettingsRow>(
"select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled,
"select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, codex_privacy_policy_json,
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, min_codex_desktop_version,
@@ -328,7 +328,7 @@ pub(crate) async fn load_runtime_settings_in_transaction(
transaction: &mut Transaction<'_, Postgres>,
) -> StoreResult<RuntimeSettings> {
let row = sqlx::query_as::<_, RuntimeSettingsRow>(
"select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled,
"select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, codex_privacy_policy_json,
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
rotation_strategy, smart_scheduling_json, model_mappings_json, usage_retention_days, ops_event_retention_days,
audit_retention_days, min_codex_desktop_version,
@@ -412,6 +412,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
openai_account_affinity = $32,
max_account_rotations = $33,
openai_session_affinity_ttl_hours = $34,
codex_privacy_policy_json = $35,
updated_at = now()
where id = 1
returning config_revision",
@@ -461,6 +462,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
.bind(update.values.openai_account_affinity.as_str())
.bind(i64::from(update.values.max_account_rotations))
.bind(i64::from(update.values.openai_session_affinity_ttl_hours))
.bind(sqlx::types::Json(&update.values.codex_privacy_policy))
.fetch_optional(&mut **transaction)
.await
.map_err(|source| postgres_unavailable("update runtime settings in transaction", source))?
@@ -523,6 +525,8 @@ struct RuntimeSettingsRow {
request_interval_ms: i64,
smart_scheduling_json: sqlx::types::Json<gateway_core::account::SmartSchedulingConfig>,
rotation_strategy: String,
codex_privacy_policy_json:
sqlx::types::Json<gateway_core::settings::privacy::CodexPrivacyPolicy>,
request_location_enabled: bool,
request_location_json: sqlx::types::Json<gateway_core::account::RequestLocation>,
model_mappings_json: sqlx::types::Json<BTreeMap<String, String>>,
@@ -579,6 +583,7 @@ fn runtime_settings_from_row(row: RuntimeSettingsRow) -> StoreResult<RuntimeSett
max_concurrent_per_account: to_u32(row.max_concurrent_per_account)?,
request_interval_ms: to_u64(row.request_interval_ms)?,
smart_scheduling: row.smart_scheduling_json.0,
codex_privacy_policy: row.codex_privacy_policy_json.0,
request_location_enabled: row.request_location_enabled,
request_location: row
.request_location_json
@@ -226,6 +226,8 @@ struct SnapshotSettingsRow {
max_account_rotations: i64,
openai_session_affinity_ttl_hours: i64,
request_location_json: sqlx::types::Json<gateway_core::account::RequestLocation>,
codex_privacy_policy_json:
sqlx::types::Json<gateway_core::settings::privacy::CodexPrivacyPolicy>,
request_location_enabled: bool,
responses_max_decompressed_body_bytes: i64,
provider_request_profiles_json:
@@ -236,7 +238,7 @@ async fn load_settings(
transaction: &mut Transaction<'_, Postgres>,
) -> StoreResult<(Revision, SettingsValues)> {
let row = sqlx::query_as::<_, SnapshotSettingsRow>(
"select config_revision, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
"select config_revision, max_concurrent_per_account, request_interval_ms, rotation_strategy, smart_scheduling_json, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, openai_guardian_reserved_concurrency, openai_account_affinity, max_account_rotations, openai_session_affinity_ttl_hours, request_location_json, request_location_enabled, codex_privacy_policy_json, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
)
.fetch_optional(&mut **transaction)
.await
@@ -281,6 +283,7 @@ async fn load_settings(
)
})
.with_request_location(row.request_location_json.0, row.request_location_enabled)
.with_codex_privacy_policy(row.codex_privacy_policy_json.0)
.with_concurrency_queues(
to_u32(row.max_waiting_per_key)?,
to_u32(row.max_waiting_per_account)?,
@@ -1,5 +1,7 @@
//! 观测存储测试入口,以及查询范围、过滤与端口合同测试
mod projection;
use futures::TryStreamExt;
use std::{
collections::BTreeMap,
@@ -0,0 +1,93 @@
//! 观测视图升级兼容性与普通聚合的覆盖索引规划回归测试
use chrono::Utc;
use serde_json::Value;
use super::{TestDatabase, seed_observability_facts};
#[tokio::test]
async fn projection_upgrade_preserves_observation_values() {
let Some(db) = TestDatabase::create_through("observation_projection_upgrade", 26).await else {
return;
};
seed_observability_facts(&db.pool, Utc::now())
.await
.unwrap();
sqlx::query(
"update model_requests
set request_observation_json = jsonb_set(request_observation_json,
'{timings,upstream}', '{\"responseMs\":700,\"engineIapiTbtMs\":2.450638}')
where id = 'req_observe_success'",
)
.execute(&db.pool)
.await
.unwrap();
let before: Vec<Value> =
sqlx::query_scalar("select to_jsonb(mr) from model_request_observations mr order by id")
.fetch_all(&db.pool)
.await
.unwrap();
super::super::TEST_MIGRATOR.run(&db.pool).await.unwrap();
let after: Vec<Value> =
sqlx::query_scalar("select to_jsonb(mr) from model_request_observations mr order by id")
.fetch_all(&db.pool)
.await
.unwrap();
assert_eq!(
after, before,
"projection upgrade must preserve every field"
);
let updatable: String = sqlx::query_scalar(
"select is_updatable from information_schema.views
where table_schema = current_schema() and table_name = 'model_request_observations'",
)
.fetch_one(&db.pool)
.await
.unwrap();
assert_eq!(updatable, "NO", "observation projection remains read-only");
db.close().await;
}
#[tokio::test]
async fn plain_observation_aggregates_can_use_a_covering_index() {
let Some(db) = TestDatabase::create("observation_covering_index").await else {
return;
};
seed_observability_facts(&db.pool, Utc::now())
.await
.unwrap();
let mut transaction = db.pool.begin().await.unwrap();
// 小夹具只验证覆盖索引路径可达,不把耗时阈值或生产规划器成本固定在测试中
sqlx::raw_sql(
"create index observation_plain_cover_idx on model_requests (started_at)
include (input_tokens, cost_amount) where recovered_at is null;
set local enable_seqscan = off",
)
.execute(&mut *transaction)
.await
.unwrap();
let plan: Value = sqlx::query_scalar(
"explain (format json)
select count(*), sum(input_tokens), sum(cost_amount)
from model_request_observations
where recovered_at is null and started_at >= now() - interval '1 day'",
)
.fetch_one(&mut *transaction)
.await
.unwrap();
assert!(
uses_covering_index(&plan[0]["Plan"]),
"plain aggregates must not require fetching observation JSON: {plan}"
);
transaction.rollback().await.unwrap();
db.close().await;
}
fn uses_covering_index(plan: &Value) -> bool {
(plan["Node Type"] == "Index Only Scan" && plan["Index Name"] == "observation_plain_cover_idx")
|| plan["Plans"]
.as_array()
.is_some_and(|children| children.iter().any(uses_covering_index))
}
@@ -18,6 +18,7 @@ pub(super) fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettin
("grok-latest".to_owned(), "grok-4.5".to_owned()),
]),
values: gateway_admin::model::settings::RuntimeSettingsValues {
codex_privacy_policy: Default::default(),
request_location_enabled: false,
request_location: Default::default(),
refresh_margin_seconds,
@@ -58,6 +59,59 @@ fn runtime_settings_keep_account_rotation_global() {
assert!(settings.validate().is_ok());
}
#[tokio::test]
async fn privacy_policy_upgrade_defaults_off_and_round_trips_in_snapshot() {
use gateway_core::settings::privacy::*;
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
let Some(database) = TestDatabase::create_through("privacy_policy", 25).await else {
return;
};
super::TEST_MIGRATOR.run(&database.pool).await.unwrap();
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let before = repository.load_runtime_settings().await.unwrap();
assert_eq!(
before.values.codex_privacy_policy,
CodexPrivacyPolicy::default()
);
let mut update = settings_with_margin(300);
update.values.codex_privacy_policy = CodexPrivacyPolicy {
enabled: true,
on_error: PrivacyFailureMode::RejectRequest,
rules: vec![PrivacyRule {
id: "remove-auth".into(),
name: "配置者控制".into(),
enabled: true,
scope: PrivacyScope::RequestHeader,
selector: "authorization".into(),
action: PrivacyAction::RemoveField,
pattern: None,
replacement: String::new(),
value: serde_json::Value::Null,
replace_all: true,
case_insensitive: false,
multi_line: false,
}],
};
let expected = update.values.codex_privacy_policy.clone();
repository.update_runtime_settings(update).await.unwrap();
let after = repository.load_runtime_settings().await.unwrap();
assert_eq!(after.values.codex_privacy_policy, expected);
assert!(after.config_revision > before.config_revision);
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
.load_runtime_snapshot()
.await
.unwrap();
assert_eq!(
serde_json::to_value(snapshot.settings).unwrap()["codex_privacy_policy"],
serde_json::to_value(expected).unwrap()
);
assert_eq!(
before.values.codex_privacy_policy,
CodexPrivacyPolicy::default()
);
database.close().await;
}
#[tokio::test]
async fn smart_settings_upgrade_preserves_selection_and_publishes_custom_config() {
use gateway_core::account::SmartSchedulingConfig;
@@ -1187,9 +1241,15 @@ async fn preferred_affinity_migration_defaults_to_strict_and_preserves_saved_mod
.await
.unwrap();
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
let mut update = settings_with_margin(3600);
update.values.openai_account_affinity = mode;
repository.update_runtime_settings(update).await.unwrap();
// 升级前只写当时已有列,现行 Repository 依赖完整迁移后的 schema
sqlx::query("update runtime_settings set openai_account_affinity = $1, config_revision = config_revision + 1, updated_at = now() where id = 1")
.bind(match mode {
AccountAffinity::Relaxed => "relaxed",
_ => "strict",
})
.execute(&database.pool)
.await
.unwrap();
super::TEST_MIGRATOR.run(&database.pool).await.unwrap();
assert_eq!(
repository
@@ -35,6 +35,7 @@ hyper-util = { version = "0.1.20", features = ["client-legacy", "http1", "tokio"
# vendored OpenSSL 固定 Linux 发布版的 TLS 画像,且不启用压缩特性。
native-tls = "=0.2.14"
reqwest = { version = "=0.12.28", features = ["json", "stream", "cookies", "rustls-tls-native-roots", "socks"] }
regex = "1.13.0"
roxmltree = "0.21.1"
semver = "1.0.27"
# 与官方 rustls 路径一致:aws-lc 支持 P-521,优先提供 X25519MLKEM768。
@@ -164,6 +164,26 @@ impl OpenAiAdminProvider {
#[async_trait]
impl ProviderAdmin for OpenAiAdminProvider {
fn compile_privacy_policy(
&self,
policy: &gateway_core::settings::privacy::CodexPrivacyPolicy,
) -> Result<
Arc<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
gateway_core::settings::privacy::PrivacyError,
> {
crate::transport::privacy::compile(policy)
}
fn preview_privacy_policy(
&self,
request: gateway_core::settings::privacy::PrivacyPreviewRequest,
) -> Result<
gateway_core::settings::privacy::PrivacyPreviewResult,
gateway_core::settings::privacy::PrivacyError,
> {
crate::transport::privacy::preview(request)
}
fn account_capabilities(
&self,
_account_id: &ProviderAccountId,
+10 -1
View File
@@ -145,7 +145,16 @@ impl Default for CodexApiConfig {
impl CodexApiConfig {
fn validate(&self) -> Result<(), OpenAiConfigError> {
if !crate::transport::valid_upstream_base_url(&self.base_url) {
let url = crate::transport::parse_upstream_base_url(&self.base_url)
.ok_or(OpenAiConfigError::InvalidField("openai.api.base_url"))?;
// OAuth 启动地址的 HTTP 例外仅用于本机联调,不沿用 API Key 中转账号的传输选择
let loopback = match url.host() {
Some(url::Host::Domain("localhost")) => true,
Some(url::Host::Ipv4(address)) => address.is_loopback(),
Some(url::Host::Ipv6(address)) => address.is_loopback(),
_ => false,
};
if url.scheme() == "http" && !loopback {
return Err(OpenAiConfigError::InvalidField("openai.api.base_url"));
}
Ok(())
@@ -1484,7 +1484,7 @@ fn parse_api_key_import(value: &Value) -> Result<ApiKeyCredentialData, CodexCred
.or_else(|| external.then(|| "https://api.openai.com/v1".to_owned()))
.ok_or(CodexCredentialAdminError::InvalidInput)?;
if external {
if !crate::transport::valid_upstream_base_url(&base_url) {
if crate::transport::parse_upstream_base_url(&base_url).is_none() {
return Err(CodexCredentialAdminError::InvalidInput);
}
// sub2api 接受服务根、版本前缀或完整 Responses 端点;仅在导入边界归一化
@@ -22,7 +22,7 @@ impl ApiKeyConfiguration {
pub(crate) fn validate(&self) -> bool {
self.base_url.len() <= 2048
&& !self.base_url.chars().any(char::is_control)
&& crate::transport::valid_upstream_base_url(&self.base_url)
&& crate::transport::parse_upstream_base_url(&self.base_url).is_some()
}
}
@@ -68,7 +68,9 @@ pub use selector::{
CodexAccountFailure, CodexCredentialLease, CodexCredentialSelector, CredentialSelectionError,
SelectCodexCredential,
};
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
pub(crate) use selector::{
CodexCyberPolicyScope, CodexSelectionModel, SelectCodexProviderEndpointCredential,
};
pub use types::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCookie, CodexCookieCaptureOutcome,
CodexCredentialData, CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret,
@@ -7,11 +7,13 @@ use std::time::{Duration, SystemTime};
use gateway_core::error::ErrorSource;
use gateway_core::account::scope::FrozenAccountScope;
use gateway_core::account::{
AccountAffinity, AccountCandidate, AccountCapacitySnapshot, AccountEligibilityPolicy,
AccountErrorReason, AccountFeedbackStats, AccountRuntimeSignals, AccountSchedulingBlocker,
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus,
CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId, QuotaEvidence,
AccountErrorReason, AccountFeedbackStats, AccountModelAccessMode, AccountRuntimeSignals,
AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector,
AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId,
QuotaEvidence,
};
use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection, WaitPriority};
use gateway_core::engine::{AttemptContext, ContinuationAttempt, policy::AccountPolicyError};
@@ -101,12 +103,53 @@ pub(crate) struct SelectCodexProviderEndpointCredential<'a> {
pub request_url: &'a Url,
pub attempt: &'a AttemptContext,
pub session_affinity: Option<&'a CodexSessionAffinity>,
/// 本端点的上游模型;提供后按账号模型权限过滤候选(如 live 语音)。
pub upstream_model: Option<&'a str>,
pub model: CodexSelectionModel<'a>,
/// 本端点只接受 OAuth 凭据时排除 API Key 账号,避免混合池选中后必然失败。
pub requires_oauth: bool,
}
/// 选号使用的模型类别,区分图片资格与不涉及模型的端点
#[derive(Clone, Copy)]
pub(crate) enum CodexSelectionModel<'a> {
NotApplicable,
Requested(&'a str),
Image(Option<&'a str>),
}
impl<'a> CodexSelectionModel<'a> {
pub(crate) const fn model(self) -> Option<&'a str> {
match self {
Self::NotApplicable => None,
Self::Requested(model) => Some(model),
Self::Image(model) => model,
}
}
pub(crate) fn allows_account(self, account: &ProviderAccount) -> bool {
// 官方 Codex 的 image_generation_available 排除 Free,API Key 不套用 ChatGPT 套餐
// https://github.com/openai/codex/commit/0a0a9b6c8f
!(matches!(self, Self::Image(_))
&& account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH
&& account
.plan_type()
.is_some_and(|plan| plan.trim().eq_ignore_ascii_case("free")))
}
pub(crate) fn allows(self, scope: &FrozenAccountScope, account: &ProviderAccountId) -> bool {
match self {
Self::NotApplicable => scope.allows(account),
Self::Requested(model) | Self::Image(Some(model)) => scope.allows_model(account, model),
// 缺失、重复或不可解析的图片模型不能绕过黑白名单;不猜上游默认值
Self::Image(None) => {
scope.allows(account)
&& scope.directory().account(account).is_some_and(|account| {
account.model_access().mode() == AccountModelAccessMode::All
})
}
}
}
}
struct CredentialSelectionInput<'a> {
requires_websocket: bool,
requires_oauth: bool,
@@ -304,8 +347,12 @@ impl CodexCredentialSelector {
session_affinity_observation: None,
guardian: false,
};
self.select_inner(&input, None, Some(request.upstream_model))
.await
self.select_inner(
&input,
None,
CodexSelectionModel::Requested(request.upstream_model),
)
.await
}
pub(crate) async fn select_with_cyber_policy(
@@ -328,7 +375,7 @@ impl CodexCredentialSelector {
self.select_inner(
&input,
cyber_policy_session_key,
Some(request.upstream_model),
CodexSelectionModel::Requested(request.upstream_model),
)
.await
}
@@ -385,9 +432,8 @@ impl CodexCredentialSelector {
/// 为不属于 Responses 文本模型目录的 Provider 原生端点选择账号
///
/// 账号范围、健康度、配额、并发租约、cookie 与认证准备仍走同一套选择链路;
/// 原生端点默认没有 Responses 模型,不套用管理员配置的文本模型权限。
/// 端点有明确上游模型(如 live 语音)时通过 `upstream_model` 让账号
/// 模型权限参与候选过滤;`requires_oauth` 限定本端点支持的认证类型。
/// Images 与 live 语音按实际请求模型执行账号权限,不依赖文本模型目录
/// `requires_oauth` 限定本端点支持的认证类型
pub(crate) async fn select_for_provider_endpoint(
&self,
request: &SelectCodexProviderEndpointCredential<'_>,
@@ -401,15 +447,14 @@ impl CodexCredentialSelector {
session_affinity_observation: request.session_affinity,
guardian: false,
};
self.select_inner(&input, None, request.upstream_model)
.await
self.select_inner(&input, None, request.model).await
}
async fn select_inner(
&self,
request: &CredentialSelectionInput<'_>,
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
upstream_model: Option<&str>,
model: CodexSelectionModel<'_>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let follow_only = !request.attempt.is_diagnostic_required_account()
&& request
@@ -423,7 +468,7 @@ impl CodexCredentialSelector {
let result = tokio::select! {
biased;
() = request.attempt.cancellation().cancelled() => Err(CredentialSelectionError::Cancelled),
result = self.select_inner_loop(request, cyber_policy_session_key, upstream_model, follow_only) => result,
result = self.select_inner_loop(request, cyber_policy_session_key, model, follow_only) => result,
};
result.map_err(|error| {
if follow_only
@@ -446,7 +491,7 @@ impl CodexCredentialSelector {
&self,
request: &CredentialSelectionInput<'_>,
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
upstream_model: Option<&str>,
model: CodexSelectionModel<'_>,
follow_only: bool,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let diagnostic = request.attempt.is_diagnostic_required_account();
@@ -561,17 +606,17 @@ impl CodexCredentialSelector {
.is_some_and(|scope| scope.allows(account.id())))
&& (!request.requires_oauth
|| account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH)
&& (diagnostic
|| upstream_model.is_none_or(|upstream_model| {
let allowed =
request.attempt.account_scope().is_some_and(|scope| {
scope.allows_model(account.id(), upstream_model)
});
if !allowed {
model_access_rejected += 1;
}
allowed
}))
&& model.allows_account(account)
&& (diagnostic || {
let allowed = request
.attempt
.account_scope()
.is_some_and(|scope| model.allows(scope, account.id()));
if !allowed {
model_access_rejected += 1;
}
allowed
})
})
.collect::<Vec<_>>();
let mut eligible = Vec::with_capacity(accounts.len());
@@ -742,7 +787,7 @@ impl CodexCredentialSelector {
}
let selection = match request
.attempt
.select_account(&self.provider_kind, upstream_model, &candidates, &context)
.select_account(&self.provider_kind, model.model(), &candidates, &context)
.await
{
Ok(selection) => selection,
@@ -3,6 +3,7 @@
use gateway_core::metering::{CalculatedCost, Usage};
use super::*;
use crate::credential::CodexSelectionModel;
use gateway_core::operation::RawJsonPayload;
impl CodexProvider {
@@ -150,6 +151,15 @@ impl CodexProvider {
context: AttemptContext,
request: RawJsonEndpointRequest,
) -> Result<ProviderStream, ProviderError> {
let requested_model = match &request.operation {
Operation::GenerateImage(image) => endpoint_requested_model(image.payload()),
_ => None,
};
let model = if matches!(&request.operation, Operation::GenerateImage(_)) {
CodexSelectionModel::Image(requested_model.as_ref().map(|model| model.as_str()))
} else {
CodexSelectionModel::NotApplicable
};
let selection_started_at = Instant::now();
let lease = self
.selector
@@ -157,8 +167,7 @@ impl CodexProvider {
request_url: &request.response_origin,
attempt: &context,
session_affinity: request.session_affinity.as_ref(),
// 图像/搜索端点没有对应的上游模型权限,也不限定认证类型。
upstream_model: None,
model,
requires_oauth: false,
})
.await
@@ -175,7 +184,7 @@ impl CodexProvider {
.execute_middleware(
operation,
provider_kind,
None,
requested_model.map(|model| model.as_str().to_owned()),
account_id,
Box::new(move |operation, middleware_headers| {
Box::pin(async move {
@@ -206,6 +215,23 @@ impl CodexProvider {
) -> Result<ProviderStream, ProviderError> {
let affinity = match &operation {
Operation::GenerateImage(image) => {
let model = endpoint_requested_model(image.payload());
let selection =
CodexSelectionModel::Image(model.as_ref().map(|model| model.as_str()));
// 中间件可改写正文,最终图片模型必须仍被当前租约账号允许
// 校验先于亲和变更和发送,不能在持有租约时重新选号
if !selection.allows_account(lease.account())
|| (!context.is_diagnostic_required_account()
&& !context
.account_scope()
.is_some_and(|scope| selection.allows(scope, lease.account_id())))
{
return Err(provider_error(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
)
.with_retry_prohibited());
}
self.image_session_affinity(image.payload(), &middleware_headers, &context)
.await?
}
@@ -317,7 +343,8 @@ impl CodexProvider {
.for_account(lease.account())
.map_err(|error| map_client_error(error, UpstreamSendState::NotSent, false).error)?
.with_responses_api_base_url(lease.authentication().responses_api_base_url())
.with_middleware_headers(middleware_headers),
.with_middleware_headers(middleware_headers)
.with_privacy(context.privacy(), context.cancellation().clone()),
response_origin: request.response_origin,
endpoint_path: request.endpoint_path,
body: request.body,
@@ -823,6 +850,7 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
&mut failure,
WebSocketRecoveryContext {
policy,
replay_boundary: ReplayBoundary::BeforeSemanticOutput,
requirement: request_transport_requirement,
retry_count: websocket_retry_count,
max_retries: stream_max_retries,
@@ -937,7 +965,6 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Sent))?;
return;
};
let replay_grace_deadline = pre_commit_events.replay_grace_deadline();
let next = tokio::select! {
biased;
_ = cancellation.cancelled() => Err(MappedProviderFailure::plain(provider_error(
@@ -948,9 +975,8 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
ProviderErrorKind::Timeout,
UpstreamSendState::Sent,
))),
_ = wait_for_replay_grace(replay_grace_deadline) => Ok(PreCommitPoll::GraceElapsed),
chunk = body.next() => match chunk {
Some(Ok(chunk)) => Ok(PreCommitPoll::Upstream(Some(chunk))),
Some(Ok(chunk)) => Ok(Some(chunk)),
Some(Err(error)) => {
log_client_upstream_error(
UpstreamErrorLogContext::new(
@@ -962,17 +988,11 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
);
Err(map_stream_error(error))
}
None => Ok(PreCommitPoll::Upstream(None)),
None => Ok(None),
},
};
let next = match next {
Ok(PreCommitPoll::Upstream(next)) => next,
Ok(PreCommitPoll::GraceElapsed) => {
for event in pre_commit_events.commit_pending(PreCommitReleaseReason::GraceTimeout) {
yield event;
}
continue;
}
Ok(next) => next,
Err(mut failure) => {
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
let rate_limits_changed = if updates.is_empty() {
@@ -996,15 +1016,12 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
};
if failure.websocket_transport_retryable
&& response_transport == CodexBackendTransport::WebSocket
&& (matches!(
failure.error.send_state(),
UpstreamSendState::Sent | UpstreamSendState::Ambiguous
) || !pre_commit_events.is_committed())
{
apply_websocket_recovery_policy(
&mut failure,
WebSocketRecoveryContext {
policy: WebSocketFailurePolicy::Budgeted,
replay_boundary: ReplayBoundary::from_semantic_output(pre_commit_events.is_committed()),
requirement: request_transport_requirement,
retry_count: websocket_retry_count,
max_retries: stream_max_retries,
@@ -69,28 +69,14 @@ impl MappedProviderFailure {
}
}
pub(super) enum PreCommitPoll<T> {
Upstream(T),
GraceElapsed,
}
pub(super) async fn wait_for_replay_grace(deadline: Option<Instant>) {
if let Some(deadline) = deadline {
tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
} else {
std::future::pending::<()>().await;
}
}
/// 提交边界前的上游事件预取
///
/// 结构事件保留到宽限期结束、语义输出、终态或 EOF,再提交已缓存 wire
/// 大段配置回显不应提前结束无感换号窗口,原始 chunk 字节数只用于诊断
/// 结构事件保留到语义输出、终态或 EOF,再提交已缓存 wire
/// 一旦提交,后续事件不再具备无痕重放资格
pub(super) struct PreCommitClientEvents {
pending: Vec<ProviderEvent>,
prefetched_bytes: usize,
replay_grace_started_at: Option<Instant>,
started_at: tokio::time::Instant,
committed: bool,
trace: TraceContext,
}
@@ -100,16 +86,15 @@ pub(super) struct PreCommitClientEvents {
pub(super) enum PreCommitReleaseReason {
SemanticOutput,
Terminal,
GraceTimeout,
Eof,
}
impl PreCommitClientEvents {
pub(super) const fn new(trace: TraceContext) -> Self {
pub(super) fn new(trace: TraceContext) -> Self {
Self {
pending: Vec::new(),
prefetched_bytes: 0,
replay_grace_started_at: None,
started_at: tokio::time::Instant::now(),
committed: false,
trace,
}
@@ -130,7 +115,6 @@ impl PreCommitClientEvents {
if self.committed {
return incoming;
}
let starts_replay_grace = incoming.iter().any(ProviderEvent::has_client_event);
self.pending.extend(incoming);
if timing_signals.semantic_output {
return self.commit_pending(PreCommitReleaseReason::SemanticOutput);
@@ -138,9 +122,6 @@ impl PreCommitClientEvents {
if completed {
return self.commit_pending(PreCommitReleaseReason::Terminal);
}
if starts_replay_grace && self.replay_grace_started_at.is_none() {
self.replay_grace_started_at = Some(Instant::now());
}
Vec::new()
}
@@ -170,15 +151,9 @@ impl PreCommitClientEvents {
pub(super) fn take_for_failure(&mut self, incoming: Vec<ProviderEvent>) -> Vec<ProviderEvent> {
self.pending.extend(incoming);
self.prefetched_bytes = 0;
self.replay_grace_started_at = None;
std::mem::take(&mut self.pending)
}
pub(super) fn replay_grace_deadline(&self) -> Option<Instant> {
self.replay_grace_started_at
.and_then(|started| started.checked_add(STREAM_REPLAY_GRACE))
}
pub(super) const fn is_committed(&self) -> bool {
self.committed
}
@@ -186,15 +161,17 @@ impl PreCommitClientEvents {
pub(super) fn commit_pending(&mut self, reason: PreCommitReleaseReason) -> Vec<ProviderEvent> {
// Provider 只记录释放缓存的原因;实际下游提交仍由 Core 记录和裁决
if self.trace.is_enabled() {
self.trace.record("provider.precommit.released", json!({
"reason": reason,
"prefetchedBytes": self.prefetched_bytes,
"waitMs": self.replay_grace_started_at.map_or(0, |started| started.elapsed().as_millis()),
}));
self.trace.record(
"provider.precommit.released",
json!({
"reason": reason,
"prefetchedBytes": self.prefetched_bytes,
"waitMs": self.started_at.elapsed().as_millis(),
}),
);
}
self.committed = true;
self.prefetched_bytes = 0;
self.replay_grace_started_at = None;
std::mem::take(&mut self.pending)
}
}
@@ -484,6 +461,7 @@ pub(super) enum WebSocketFailurePolicy {
pub(super) struct WebSocketRecoveryContext<'a> {
pub(super) policy: WebSocketFailurePolicy,
pub(super) replay_boundary: ReplayBoundary,
pub(super) requirement: TransportRequirement,
pub(super) retry_count: u32,
pub(super) max_retries: u32,
@@ -576,34 +554,10 @@ pub(super) fn apply_websocket_recovery_policy(
.session_transport_recovery
.record_websocket_failure(key, context.max_retries)
});
let send_state = failure.error.send_state();
let post_send = matches!(
send_state,
UpstreamSendState::Sent | UpstreamSendState::Ambiguous
);
if send_state == UpstreamSendState::Ambiguous || (post_send && !failure.error.replay_is_safe())
{
tracing::warn!(
request_id = context.request_id,
attempt_index = context.attempt_index,
account_id = context.account_id,
websocket_failure_kind = failure.error.kind().as_str(),
websocket_failure_code = failure
.error
.diagnostic()
.and_then(ProviderDiagnostic::code)
.unwrap_or(""),
upstream_send_state = ?send_state,
transport_requirement = context.requirement.as_str(),
continuation_recovery_action = "client_replay_required",
session_http_fallback = session_budget_exhausted,
session_affinity_present = context.session_transport_key.is_some(),
session_transport_key_hash = context.session_transport_key_hash.unwrap_or(""),
"OpenAI upstream WebSocket failed after payload send; proxy replay was suppressed"
);
// 已交付响应只影响后续请求的会话传输选择,不能重放当前请求
if matches!(context.replay_boundary, ReplayBoundary::AfterSemanticOutput) {
return;
}
if matches!(
context.requirement,
TransportRequirement::ExactWebSocketContinuation | TransportRequirement::HttpRequired
@@ -611,6 +565,8 @@ pub(super) fn apply_websocket_recovery_policy(
return;
}
// 有效输出前,已发送或结果不明也允许按预算尽力恢复
// 保留发送事实和 replay_is_safe 原值,重试意图不能伪装成上游未执行的证明
// 传输恢复必须保持原账号可调度;最终 HTTP attempt 若仍失败,再按真实 HTTP
// 结果更新账号健康度,避免中间 WS 错误把同账号钉选提前冷却掉
failure.account_failure = None;
@@ -643,6 +599,8 @@ pub(super) fn apply_websocket_recovery_policy(
websocket_retry_count = retry_index.get(),
websocket_max_retries = context.max_retries,
websocket_retry_delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
upstream_send_state = ?failure.error.send_state(),
replay_proved_safe = failure.error.replay_is_safe(),
transport_requirement = context.requirement.as_str(),
"OpenAI upstream WebSocket failed; retrying the same account"
);
@@ -890,6 +848,13 @@ pub(super) fn map_client_error(
let connect_retry = !local_connection_capacity
&& matches!(&error, CodexClientError::Http(error) if transient_http_connect(error));
let mut failure = match &error {
CodexClientError::Privacy(_) => MappedProviderFailure::plain(
provider_error(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
)
.with_retry_prohibited(),
),
CodexClientError::ConnectionBudgetExhausted => MappedProviderFailure::plain(
provider_error(ProviderErrorKind::Timeout, UpstreamSendState::NotSent)
.with_connection_retry(gateway_core::engine::AttemptTransport::Fallback),
@@ -1115,6 +1080,11 @@ fn client_diagnostic(error: &CodexClientError) -> Option<ProviderDiagnostic> {
"invalid_sse",
"OpenAI HTTP returned an invalid Responses event stream".to_owned(),
),
CodexClientError::Privacy(error) => (
"prepare",
"privacy_policy_rejected",
format!("Privacy rule {}: {}", error.rule_index + 1, error.reason),
),
CodexClientError::InvalidHeaderName(_) => (
"prepare",
"invalid_header_name",
@@ -1217,6 +1187,7 @@ fn websocket_diagnostic(error: &CodexWebSocketExchangeError) -> ProviderDiagnost
CodexWebSocketExchangeError::OriginHalfOpenBusy => ("connect", "origin_half_open_busy"),
CodexWebSocketExchangeError::InvalidRequest(_) => ("prepare", "invalid_request"),
CodexWebSocketExchangeError::SendTimeout { .. } => ("send", "send_timeout"),
CodexWebSocketExchangeError::SendNotStarted => ("send", "send_not_started"),
CodexWebSocketExchangeError::ReceiveIdleTimeout { .. } => {
("receive", "receive_idle_timeout")
}
@@ -1299,6 +1270,9 @@ fn websocket_diagnostic_message(error: &CodexWebSocketExchangeError) -> Provider
CodexWebSocketExchangeError::SharedConnectFailed => {
"OpenAI shared WebSocket connection failed before payload send".to_owned()
}
CodexWebSocketExchangeError::SendNotStarted => {
"OpenAI WebSocket connection closed before payload send started".to_owned()
}
CodexWebSocketExchangeError::SendTimeout { timeout } => {
format!("OpenAI WebSocket payload send timed out after {timeout:?}")
}
@@ -1618,6 +1592,7 @@ pub(super) fn websocket_send_state(error: &CodexWebSocketExchangeError) -> Upstr
| CodexWebSocketExchangeError::OriginCircuitOpen
| CodexWebSocketExchangeError::OriginHalfOpenBusy
| CodexWebSocketExchangeError::SharedConnectFailed
| CodexWebSocketExchangeError::SendNotStarted
| CodexWebSocketExchangeError::ContinuationUnavailable { .. } => UpstreamSendState::NotSent,
CodexWebSocketExchangeError::Upstream(_)
| CodexWebSocketExchangeError::ConnectionLimitReached(_)
@@ -1655,6 +1630,7 @@ pub(super) fn websocket_error_kind(error: &CodexWebSocketExchangeError) -> Provi
CodexWebSocketExchangeError::Upstream(_) => ProviderErrorKind::Unavailable,
CodexWebSocketExchangeError::ConnectionLimitReached(_) => ProviderErrorKind::RateLimited,
CodexWebSocketExchangeError::Transport(_)
| CodexWebSocketExchangeError::SendNotStarted
| CodexWebSocketExchangeError::Connect(_)
| CodexWebSocketExchangeError::StreamEndedBeforeTerminal { .. }
| CodexWebSocketExchangeError::ReusedConnectionDiedBeforeFirstEvent { .. } => {
@@ -32,7 +32,7 @@ use uuid::Uuid;
use super::*;
use crate::credential::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexCredentialRepository,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexCredentialRepository, CodexSelectionModel,
SelectCodexProviderEndpointCredential,
};
use crate::transport::headers::websocket_header_pairs;
@@ -252,7 +252,7 @@ impl CodexProvider {
request_url: &self.live_calls_url,
attempt: &context,
session_affinity: session_affinity.as_ref(),
upstream_model: Some(upstream_model.as_str()),
model: CodexSelectionModel::Requested(upstream_model.as_str()),
// realtime calls 端点绑定 ChatGPT OAuth 身份;在候选阶段就排除
// API Key 账号,避免混合账号池选中不支持语音的账号后必然失败。
requires_oauth: true,
@@ -125,9 +125,6 @@ const WEBSOCKET_TRANSPORT: &str = "websocket";
// 在已观测到的 Codex OAuth 上游 16 MiB 附近消息边界前留出传输 metadata 余量
const WEBSOCKET_HTTP_FALLBACK_THRESHOLD_BYTES: usize = 15 * 1024 * 1024;
const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024;
/// 短暂保留 response.created 等结构事件,让随后到达的明确拒绝可以无感换号;
/// 到期即放行,避免模型长时间思考时让客户端一直收不到首事件
const STREAM_REPLAY_GRACE: Duration = Duration::from_millis(2_500);
// 额度拒绝后的 usage 补查自身执行时长上限;结算等待的随机延迟见 jitter 模块
const QUOTA_FAILURE_REFRESH_TIMEOUT: Duration = Duration::from_secs(5);
pub const OFFICIAL_CODEX_BASE_PATH: &str = "/backend-api";
@@ -376,6 +373,16 @@ impl fmt::Debug for CodexProvider {
#[async_trait]
impl Provider for CodexProvider {
fn compile_privacy_policy(
&self,
policy: &gateway_core::settings::privacy::CodexPrivacyPolicy,
) -> Result<
Arc<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
gateway_core::settings::privacy::PrivacyError,
> {
crate::transport::privacy::compile(policy)
}
fn resolve_request_profile(
&self,
configuration: &gateway_core::account::OpaqueProviderData,
@@ -1016,7 +1023,8 @@ impl CodexProvider {
.with_responses_api_base_url(lease.authentication().responses_api_base_url())
.with_connection_budget(context.connection_budget().clone())
.with_response_control(context.response_control().cloned())
.with_middleware_headers(middleware_headers),
.with_middleware_headers(middleware_headers)
.with_privacy(context.privacy(), context.cancellation().clone()),
response_origin: self.responses_url.clone(),
request: upstream_request,
upstream_model,
@@ -8,7 +8,7 @@ pub(super) fn endpoint_requested_model(
if payload.protocol() != PROVIDER_NAME {
return None;
}
// 只读取 model,避免把编辑请求中的整张图片复制到观测数据
// 选号与观测共用模型提取,跳过编辑请求中的图片且不改写原始正文
#[derive(Deserialize)]
struct RequestModel {
model: Option<Value>,
@@ -195,6 +195,8 @@ impl fmt::Debug for CodexClientVisibleUpstreamResponse {
/// Codex 上游 HTTP 客户端错误
#[derive(Error)]
pub enum CodexClientError {
#[error("privacy policy rejected the request: {0}")]
Privacy(#[from] gateway_core::settings::privacy::PrivacyError),
#[error("connection recovery budget exhausted")]
ConnectionBudgetExhausted,
/// Reqwest 传输失败
@@ -279,6 +281,7 @@ impl fmt::Debug for CodexClientError {
Self::ConnectionBudgetExhausted => {
formatter.write_str("CodexClientError::ConnectionBudgetExhausted")
}
Self::Privacy(error) => std::fmt::Debug::fmt(error, formatter),
Self::Http(_) => formatter.write_str("CodexClientError::Http([REDACTED])"),
Self::HttpJson(_) => formatter.write_str("CodexClientError::HttpJson([REDACTED])"),
Self::ErrorBodyRead {
@@ -345,7 +348,8 @@ impl CodexClientError {
Self::Upstream { transport, .. } | Self::ErrorBodyRead { transport, .. } => {
Some(*transport)
}
Self::ConnectionBudgetExhausted
Self::Privacy(_)
| Self::ConnectionBudgetExhausted
| Self::CustomCa(_)
| Self::InvalidHeaderName(_)
| Self::InvalidHeaderValue(_)
@@ -677,6 +681,10 @@ impl OpenAiUpstreamProtocol {
/// Codex HTTP/SSE 上游客户端
#[derive(Clone)]
pub struct CodexBackendClient {
pub(super) privacy: Option<(
Arc<dyn gateway_core::settings::privacy::CompiledPrivacyPolicy>,
gateway_core::lifecycle::CancellationToken,
)>,
pub(super) timezone: gateway_core::time::DeploymentTimeZone,
pub(super) response_control: Option<gateway_core::engine::response_control::ResponseControl>,
pub(super) connection_budget: Option<gateway_core::engine::connection::ConnectionBudget>,
@@ -16,7 +16,7 @@ use super::{
};
impl CodexBackendClient {
/// 向固定 Provider 端点发送一次原始 JSON;请求与成功响应正文均不经过 serde
/// 向固定 Provider 端点发送 JSON,仅启用隐私规则时解码请求正文
pub(crate) async fn post_raw_json(
&self,
endpoint_path: &'static str,
@@ -49,6 +49,21 @@ impl CodexBackendClient {
context.turn_metadata,
);
self.append_middleware_headers(&mut headers)?;
let body = if self.privacy.is_some() {
let mut value: serde_json::Value =
serde_json::from_slice(&body).map_err(CodexClientError::RequestBodyEncode)?;
let original = value.clone();
self.apply_privacy(&mut value, &mut headers)?;
if value == original {
body
} else {
bytes::Bytes::from(
serde_json::to_vec(&value).map_err(CodexClientError::RequestBodyEncode)?,
)
}
} else {
body
};
let trace = context
.trace
@@ -66,6 +66,7 @@ impl CodexBackendClient {
) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
Self {
privacy: None,
timezone: Default::default(),
connection_budget: None,
response_control: None,
@@ -113,7 +114,7 @@ impl CodexBackendClient {
upstream_request: &CodexResponsesRequest,
context: CodexRequestContext<'_>,
) -> CodexClientResult<CodexBackendStreamingResponse> {
let headers = self.request_headers_for_http_response(upstream_request, context)?;
let mut headers = self.request_headers_for_http_response(upstream_request, context)?;
let headers_started_at = Instant::now();
// OAuth 请求遵循 Codex 压缩合同;API Key 上游使用普通 JSON
// Codex 上游只交付 SSE;即使下游请求 `stream: false`,也要上游流式执行,
@@ -122,6 +123,11 @@ impl CodexBackendClient {
// 直接透传给 Codex,否则上游会以 400 拒绝非流式请求
let mut upstream_body = upstream_request.body().clone();
upstream_body.insert("stream".to_owned(), serde_json::Value::Bool(true));
let mut upstream_body = serde_json::Value::Object(upstream_body);
if self.protocol == OpenAiUpstreamProtocol::Codex {
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
}
self.apply_privacy(&mut upstream_body, &mut headers)?;
let body =
serde_json::to_vec(&upstream_body).map_err(CodexClientError::RequestBodyEncode)?;
let endpoint = endpoint_url(&self.base_url, self.protocol.responses_path());
@@ -141,9 +147,8 @@ impl CodexBackendClient {
);
trace.capture("upstream.request.body", &body);
let client = self.http_opening_client()?;
let mut outbound = client.post(endpoint).headers(headers);
let outbound = client.post(endpoint).headers(headers);
let body = if self.protocol == OpenAiUpstreamProtocol::Codex {
outbound = outbound.header(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
zstd::stream::encode_all(std::io::Cursor::new(body), 3)
.map_err(CodexClientError::RequestCompression)?
} else {
@@ -278,8 +283,18 @@ impl CodexBackendClient {
});
}
let websocket_request = websocket_upstream_request(request);
let headers = self.request_headers_for_websocket_response(&websocket_request, context)?;
let mut websocket_request = websocket_upstream_request(request);
let mut headers =
self.request_headers_for_websocket_response(&websocket_request, context)?;
let mut body = serde_json::Value::Object(std::mem::take(websocket_request.body_mut()));
let original_headers = self.privacy.as_ref().map(|_| headers.clone());
self.apply_privacy(&mut body, &mut headers)?;
*websocket_request.body_mut() = body.as_object().cloned().ok_or_else(|| {
CodexClientError::Privacy(gateway_core::settings::privacy::PrivacyError {
rule_index: 0,
reason: "WS 请求正文必须是对象",
})
})?;
let mut websocket_create = CodexWebSocketConnection::responses_create_request_for_path(
&self.base_url,
self.protocol.responses_path(),
@@ -313,7 +328,11 @@ impl CodexBackendClient {
tracing::warn!(error = %error, "Failed to write Codex WebSocket audit artifact");
}
}
let connection_profile = websocket_connection_profile(&headers, &self.middleware_headers);
let connection_profile = websocket_connection_profile(
&headers,
&self.middleware_headers,
original_headers.as_ref(),
);
let pool_key =
self.websocket_pool_key(request, context, pool_account_id, &connection_profile);
let pool_log_context = pool_key.as_ref().map(WebSocketPoolLogContext::from_key);
@@ -664,6 +683,7 @@ async fn read_model_catalog_body(response: ReqwestResponse) -> CodexClientResult
fn websocket_connection_profile(
headers: &HeaderMap,
middleware_headers: &[gateway_core::engine::middleware::MiddlewareHeader],
original_headers: Option<&HeaderMap>,
) -> String {
let mut profile = [
"originator",
@@ -693,6 +713,38 @@ fn websocket_connection_profile(
profile.push('\0');
profile.push_str(&hex::encode(digest.finalize()));
}
if let Some(original) = original_headers {
use sha2::{Digest, Sha256};
let names: std::collections::BTreeSet<_> = original
.keys()
.chain(headers.keys())
.map(|name| name.as_str())
.collect();
let mut digest = Sha256::new();
let mut changed = false;
for name in names {
if original
.get_all(name)
.iter()
.eq(headers.get_all(name).iter())
{
continue;
}
changed = true;
digest.update(name.len().to_le_bytes());
digest.update(name.as_bytes());
digest.update(headers.get_all(name).iter().count().to_le_bytes());
for value in headers.get_all(name) {
digest.update(value.len().to_le_bytes());
digest.update(value.as_bytes());
}
}
// 改写后的握手头必须参与池身份,避免复用仍携带旧隐私值的连接
if changed {
profile.push('\0');
profile.push_str(&hex::encode(digest.finalize()));
}
}
profile
}
@@ -31,22 +31,16 @@ pub fn endpoint_url(base_url: &str, endpoint_path: &str) -> String {
)
}
/// 上游启动配置与账号地址共用同一校验,HTTP 仅允许本机联调
pub(crate) fn valid_upstream_base_url(value: &str) -> bool {
let Ok(url) = url::Url::parse(value) else {
return false;
};
let loopback = match url.host() {
Some(url::Host::Domain("localhost")) => true,
Some(url::Host::Ipv4(address)) => address.is_loopback(),
Some(url::Host::Ipv6(address)) => address.is_loopback(),
_ => false,
};
((url.scheme() == "https" && url.host_str().is_some()) || (url.scheme() == "http" && loopback))
/// 共用上游地址格式校验;启动配置可在解析结果上施加更严格的传输约束
pub(crate) fn parse_upstream_base_url(value: &str) -> Option<url::Url> {
let url = url::Url::parse(value).ok()?;
(matches!(url.scheme(), "https" | "http")
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none()
&& url.fragment().is_none())
.then_some(url)
}
/// 返回与 base path 对应的唯一 usage endpoint
@@ -21,7 +21,7 @@ mod response_meta;
pub(crate) mod session;
pub mod subscription;
pub(crate) use downstream::normalize_selected_codex_downstream_body;
pub(crate) use endpoints::valid_upstream_base_url;
pub(crate) use endpoints::parse_upstream_base_url;
pub mod tls;
pub mod usage;
pub mod websocket;
@@ -72,3 +72,5 @@ pub use self::{
CodexWebSocketPool, CodexWebSocketPoolConfig, CodexWebSocketPoolKey, WebSocketPoolDecision,
},
};
pub mod privacy;
@@ -0,0 +1,288 @@
//! 对独立 metadata 副本及已识别自动上下文进行解码、改写与原位写回
use gateway_core::settings::privacy::PrivacyScope;
use reqwest::header::{HeaderMap, HeaderValue};
use serde_json::Value;
use super::{Budget, rule::CompiledRule};
const METADATA_KEYS: &[&str] = &["turnMetadata", "turn_metadata", "x-codex-turn-metadata"];
pub(super) fn apply(
rule: &CompiledRule,
body: &mut Value,
headers: &mut HeaderMap,
turn_metadata: &mut Option<String>,
budget: &mut Budget<'_>,
) -> Result<usize, &'static str> {
match rule.rule.scope {
PrivacyScope::TurnMetadata => {
let mut count = 0;
if let Some(raw) = turn_metadata {
count += metadata(rule, raw, budget)?;
}
for (name, value) in headers.iter_mut() {
if name == "x-codex-turn-metadata" {
let mut raw = std::str::from_utf8(value.as_bytes())
.map_err(|_| "metadata 请求头编码无效")?
.to_owned();
count += metadata(rule, &mut raw, budget)?;
*value = HeaderValue::from_str(&raw).map_err(|_| "metadata 请求头编码无效")?;
}
}
count += metadata_fields(rule, body, budget)?;
if let Some(client) = body.get_mut("client_metadata") {
count += metadata_fields(rule, client, budget)?;
}
Ok(count)
}
PrivacyScope::DesktopGitContext | PrivacyScope::EnvironmentText => {
contexts(rule, body, budget)
}
PrivacyScope::RequestBody => rule.apply(body, budget),
PrivacyScope::RequestHeader => {
let name = reqwest::header::HeaderName::from_bytes(rule.rule.selector.as_bytes())
.map_err(|_| "请求头名称无效")?;
let mut items = headers
.get_all(&name)
.iter()
.map(|value| {
std::str::from_utf8(value.as_bytes())
.map(|s| Value::String(s.to_owned()))
.map_err(|_| "目标请求头不是文本")
})
.collect::<Result<Vec<_>, _>>()?;
if items.is_empty() {
return Ok(0);
}
let mut count = 0;
for index in (0..items.len()).rev() {
// 复用同一目标执行器,删除根标记只在请求头承载点解释
let removed = rule.apply_value(&mut items[index], budget, &mut count)?;
if removed {
items.remove(index);
}
}
if count == 0 {
return Ok(0);
}
headers.remove(&name);
for value in items {
let value = HeaderValue::from_str(value.as_str().ok_or("请求头只能保存字符串")?)
.map_err(|_| "替换结果不能作为请求头")?;
// WS opening 会忽略不能转为 ASCII 文本的头,预览应提前暴露这个失败
value
.to_str()
.map_err(|_| "替换结果必须是 ASCII 请求头值")?;
headers.append(name.clone(), value);
}
Ok(count)
}
}
}
fn metadata_fields(
rule: &CompiledRule,
object: &mut Value,
budget: &mut Budget<'_>,
) -> Result<usize, &'static str> {
let mut count = 0;
for key in METADATA_KEYS {
if let Some(value) = object.get_mut(*key) {
match value {
Value::String(raw) => count += metadata(rule, raw, budget)?,
Value::Object(_) => count += rule.apply(value, budget)?,
_ => return Err("metadata 必须是 JSON 对象或编码后的字符串"),
}
}
}
Ok(count)
}
fn metadata(
rule: &CompiledRule,
raw: &mut String,
budget: &mut Budget<'_>,
) -> Result<usize, &'static str> {
budget.text(raw)?;
let mut value: Value = serde_json::from_str(raw).map_err(|_| "metadata JSON 无法解析")?;
if !value.is_object() {
return Err("metadata 必须是对象");
}
let count = rule.apply(&mut value, budget)?;
if count > 0 {
let encoded = crate::transport::request::serialize_ascii_turn_metadata(&value)
.ok_or("metadata 无法编码")?;
if encoded.len() > super::MAX_VALUE_BYTES {
return Err("metadata 编码结果超出大小限制");
}
*raw = encoded;
}
Ok(count)
}
fn contexts(
rule: &CompiledRule,
body: &mut Value,
budget: &mut Budget<'_>,
) -> Result<usize, &'static str> {
let Some(items) = body.get_mut("input").and_then(Value::as_array_mut) else {
return Ok(0);
};
let mut count = 0;
for item in items {
budget.step()?;
let role = item
.get("role")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let desktop = rule.rule.scope == PrivacyScope::DesktopGitContext;
if !(role == "user" || (!desktop && role == "developer")) {
continue;
}
let (kind, tag) = if desktop {
(
"additional_content.codex_git_state",
"external_codex_git_state",
)
} else if role == "developer" {
(
"additional_content.codex_apps_client_time_context",
"codex_apps_client_time_context",
)
} else {
("environments.environment_context", "environment_context")
};
let kinds = item
.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds")
.cloned();
let Some(parts) = item.get_mut("content").and_then(Value::as_array_mut) else {
continue;
};
for (index, part) in parts.iter_mut().enumerate() {
if let Some(kinds) = &kinds
&& kinds.get(index).and_then(Value::as_str) != Some(kind)
{
continue;
}
if part.get("type").and_then(Value::as_str) != Some("input_text") {
continue;
}
let Some(Value::String(text)) = part.get_mut("text") else {
continue;
};
let full = text.trim().starts_with(&format!("<{tag}>"))
&& text.trim().ends_with(&format!("</{tag}>"));
if !full && kinds.is_none() {
continue;
}
if !full {
return Err("已识别上下文不完整,可能已被截断");
}
budget.text(text)?;
count += if desktop {
git(rule, text, budget)?
} else {
environment(rule, text, budget)?
};
}
}
Ok(count)
}
fn git(
rule: &CompiledRule,
text: &mut String,
budget: &mut Budget<'_>,
) -> Result<usize, &'static str> {
let document = parse_xml(text)?;
budget.step()?;
let root = document.root_element();
if root.tag_name().name() != "external_codex_git_state" {
return Err("Desktop Git 包裹不匹配");
}
let nodes: Vec<_> = root
.descendants()
.filter(|node| {
node.has_tag_name("git")
&& node
.parent()
.is_some_and(|p| p.has_tag_name("environment_context"))
})
.collect();
if nodes.len() != 1 {
return Err("Desktop Git 数据节点不明确");
}
let git = nodes[0];
if git.children().any(|node| !node.is_text()) {
return Err("Desktop Git 数据不是文本");
}
let raw: String = git.children().filter_map(|node| node.text()).collect();
let mut value: Value = serde_json::from_str(&raw).map_err(|_| "Desktop Git JSON 无法解析")?;
if !value.is_object() {
return Err("Desktop Git JSON 必须是对象");
}
let count = rule.apply(&mut value, budget)?;
if count > 0 {
let range = git.range();
let fragment = &text[range.clone()];
let start = git
.first_child()
.ok_or("Desktop Git 数据为空")?
.range()
.start;
let end = range.start + fragment.rfind("</").ok_or("Desktop Git 包裹无效")?;
let encoded = serde_json::to_string(&value).map_err(|_| "Desktop Git JSON 无法编码")?;
let escaped = encoded
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;");
if text.len() - (end - start) + escaped.len() > super::MAX_VALUE_BYTES {
return Err("Desktop Git 编码结果超出大小限制");
}
text.replace_range(start..end, &escaped);
}
Ok(count)
}
fn environment(
rule: &CompiledRule,
text: &mut String,
budget: &mut Budget<'_>,
) -> Result<usize, &'static str> {
let before = xml_shape(text, budget)?;
let mut value = Value::String(text.clone());
let count = rule.apply(&mut value, budget)?;
let result = value.as_str().ok_or("环境上下文必须是文本")?;
if xml_shape(result, budget)? != before {
return Err("替换不能改变环境上下文包裹结构");
}
*text = result.to_owned();
Ok(count)
}
fn parse_xml(text: &str) -> Result<roxmltree::Document<'_>, &'static str> {
roxmltree::Document::parse_with_options(
text,
roxmltree::ParsingOptions {
nodes_limit: 10_000,
..Default::default()
},
)
.map_err(|_| "上下文 XML 无法解析或节点数量超限")
}
fn xml_shape(text: &str, budget: &mut Budget<'_>) -> Result<Vec<(String, usize)>, &'static str> {
let document = parse_xml(text)?;
let mut shape = Vec::new();
for node in document.descendants().filter(|node| node.is_element()) {
budget.step()?;
let depth = node.ancestors().take(65).count();
if depth > 64 {
return Err("上下文 XML 嵌套超过 64 层");
}
shape.push((node.tag_name().name().to_owned(), depth));
}
Ok(shape)
}
@@ -0,0 +1,275 @@
//! OpenAI 隐私规则的统一编译与执行,供设置预览和实际出站共享
mod carrier;
mod rule;
mod selector;
use std::{
collections::BTreeSet,
sync::Arc,
time::{Duration, Instant},
};
use gateway_core::settings::privacy::{
CodexPrivacyPolicy, CompiledPrivacyPolicy, PrivacyError, PrivacyFailureMode,
PrivacyPreviewRequest, PrivacyPreviewResult, PrivacyRuleOutcome,
};
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::Value;
const MAX_VALUE_BYTES: usize = 1024 * 1024;
const MAX_REQUEST_BYTES: usize = 32 * 1024 * 1024;
const MAX_PREVIEW_BYTES: usize = 256 * 1024;
struct Policy {
enabled: bool,
failure: PrivacyFailureMode,
rules: Vec<rule::CompiledRule>,
}
impl std::fmt::Debug for Policy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompiledCodexPrivacyPolicy")
.field("rule_count", &self.rules.len())
.finish()
}
}
struct Budget<'a> {
started: Instant,
steps: usize,
text_bytes: usize,
cancelled: &'a dyn Fn() -> bool,
}
impl Budget<'_> {
fn step(&mut self) -> Result<(), &'static str> {
self.steps += 1;
if (self.cancelled)() {
return Err("请求已取消或超时");
}
if self.steps > 10_000 || self.started.elapsed() > Duration::from_millis(100) {
return Err("规则执行预算超限");
}
Ok(())
}
fn text(&mut self, text: &str) -> Result<(), &'static str> {
self.step()?;
self.text_bytes = self.text_bytes.saturating_add(text.len());
if text.len() > MAX_VALUE_BYTES || self.text_bytes > 16 * MAX_VALUE_BYTES {
return Err("目标文本超出大小限制");
}
Ok(())
}
}
pub fn compile(
policy: &CodexPrivacyPolicy,
) -> Result<Arc<dyn CompiledPrivacyPolicy>, PrivacyError> {
if policy.rules.len() > 32 {
return Err(PrivacyError {
rule_index: 0,
reason: "最多配置 32 条规则",
});
}
let mut ids = BTreeSet::new();
let rules = policy
.rules
.iter()
.enumerate()
.map(|(rule_index, rule)| {
if !ids.insert(&rule.id) {
return Err(PrivacyError {
rule_index,
reason: "规则 ID 重复",
});
}
rule::CompiledRule::compile(rule).map_err(|reason| PrivacyError { rule_index, reason })
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Arc::new(Policy {
enabled: policy.enabled,
failure: policy.on_error,
rules,
}))
}
impl CompiledPrivacyPolicy for Policy {
fn apply(
&self,
body: &mut Value,
headers: &mut HeaderMap,
turn_metadata: &mut Option<String>,
cancelled: &dyn Fn() -> bool,
) -> Result<Vec<PrivacyRuleOutcome>, PrivacyError> {
let mut outcomes = Vec::new();
let mut budget = Budget {
started: Instant::now(),
steps: 0,
text_bytes: 0,
cancelled,
};
for (rule_index, rule) in self.rules.iter().enumerate() {
let mut outcome = PrivacyRuleOutcome {
rule_id: rule.rule.id.clone(),
matches: 0,
status: "disabled".to_owned(),
reason: None,
};
if self.enabled && rule.rule.enabled {
// 一条规则的所有承载副本同成同败,失败不会留下部分改写
let result = (|| {
budget.step()?;
check_size(body, MAX_REQUEST_BYTES)?;
let mut next_body = body.clone();
let mut next_headers = headers.clone();
let mut next_metadata = turn_metadata.clone();
let count = carrier::apply(
rule,
&mut next_body,
&mut next_headers,
&mut next_metadata,
&mut budget,
)?;
check_size(&next_body, MAX_REQUEST_BYTES)?;
budget.step()?;
*body = next_body;
*headers = next_headers;
*turn_metadata = next_metadata;
Ok(count)
})();
match result {
Ok(count) => {
outcome.matches = count;
outcome.status =
if count == 0 { "unmatched" } else { "applied" }.to_owned();
}
Err(reason) => {
if self.failure == PrivacyFailureMode::RejectRequest || cancelled() {
return Err(PrivacyError { rule_index, reason });
}
outcome.status = "skipped".to_owned();
outcome.reason = Some(reason.to_owned());
}
}
}
outcomes.push(outcome);
}
Ok(outcomes)
}
}
pub fn preview(request: PrivacyPreviewRequest) -> Result<PrivacyPreviewResult, PrivacyError> {
check_size(&request.body, MAX_PREVIEW_BYTES).map_err(|reason| PrivacyError {
rule_index: 0,
reason,
})?;
let size = request
.headers
.iter()
.map(|(key, values)| key.len() + values.iter().map(String::len).sum::<usize>())
.sum::<usize>()
+ request.turn_metadata.as_ref().map_or(0, String::len);
if size > MAX_PREVIEW_BYTES {
return Err(PrivacyError {
rule_index: 0,
reason: "预览样本最多 256 KiB",
});
}
let mut headers = HeaderMap::new();
for (key, values) in request.headers {
let name = HeaderName::from_bytes(key.as_bytes()).map_err(|_| PrivacyError {
rule_index: 0,
reason: "预览请求头名称无效",
})?;
for value in values {
headers.append(
name.clone(),
HeaderValue::from_str(&value).map_err(|_| PrivacyError {
rule_index: 0,
reason: "预览请求头值无效",
})?,
);
}
}
let mut body = request.body;
let mut turn_metadata = request.turn_metadata;
let outcomes =
compile(&request.policy)?.apply(&mut body, &mut headers, &mut turn_metadata, &|| false)?;
let headers = headers
.keys()
.map(|key| {
(
key.as_str().to_owned(),
headers
.get_all(key)
.iter()
.filter_map(|value| {
std::str::from_utf8(value.as_bytes())
.ok()
.map(str::to_owned)
})
.collect(),
)
})
.collect();
Ok(PrivacyPreviewResult {
body,
headers,
turn_metadata,
outcomes,
})
}
fn check_size(value: &Value, limit: usize) -> Result<(), &'static str> {
struct Counter(usize);
impl std::io::Write for Counter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0 = self
.0
.checked_sub(bytes.len())
.ok_or_else(|| std::io::Error::other("size limit"))?;
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
serde_json::to_writer(Counter(limit), value).map_err(|_| "请求或预览样本超出大小限制")
}
impl super::client::CodexBackendClient {
pub(crate) fn with_privacy(
mut self,
policy: Option<Arc<dyn CompiledPrivacyPolicy>>,
cancellation: gateway_core::lifecycle::CancellationToken,
) -> Self {
self.privacy = policy.map(|policy| (policy, cancellation));
self
}
/// 所有宿主头和 WS 投影完成后再执行,配置者可改写最终业务字段
pub(super) fn apply_privacy(
&self,
body: &mut Value,
headers: &mut HeaderMap,
) -> Result<(), super::client::CodexClientError> {
if let Some((policy, cancellation)) = &self.privacy {
let outcomes =
policy.apply(body, headers, &mut None, &|| cancellation.is_cancelled())?;
for (index, outcome) in outcomes
.iter()
.enumerate()
.filter(|(_, outcome)| outcome.status == "skipped")
{
tracing::warn!(
rule_index = index,
reason = outcome.reason,
"隐私规则未生效,按配置跳过"
);
}
}
Ok(())
}
}
@@ -0,0 +1,227 @@
//! 单条规则的编译、捕获组校验和受预算约束的值修改
use std::collections::BTreeSet;
use gateway_core::settings::privacy::{PrivacyAction, PrivacyRule, PrivacyScope};
use regex::{Regex, RegexBuilder};
use serde_json::Value;
use super::{Budget, MAX_VALUE_BYTES, selector};
pub(super) struct CompiledRule {
pub(super) rule: PrivacyRule,
pub(super) path: Vec<selector::Segment>,
regex: Option<Regex>,
}
impl CompiledRule {
pub(super) fn compile(rule: &PrivacyRule) -> Result<Self, &'static str> {
if rule.id.is_empty() || rule.id.len() > 64 || rule.name.is_empty() || rule.name.len() > 192
{
return Err("规则 ID 或名称长度不合法");
}
if rule.replacement.len() > 4096
|| serde_json::to_vec(&rule.value).map_or(true, |v| v.len() > 4096)
{
return Err("替换内容最多 4096 字节");
}
let path = if rule.scope == PrivacyScope::RequestHeader {
Vec::new()
} else {
selector::parse(&rule.selector)?
};
if rule.scope == PrivacyScope::RequestHeader {
reqwest::header::HeaderName::from_bytes(rule.selector.as_bytes())
.map_err(|_| "请求头名称无效")?;
if rule.action == PrivacyAction::RenameKey {
return Err("请求头范围不支持对象键名替换");
}
}
if rule.scope == PrivacyScope::EnvironmentText && !path.is_empty() {
return Err("环境文本使用 $ 选择整个文本值");
}
let regex = rule
.pattern
.as_ref()
.map(|pattern| {
if pattern.len() > 4096 {
return Err("正则表达式最多 4096 字节");
}
RegexBuilder::new(pattern)
.case_insensitive(rule.case_insensitive)
.multi_line(rule.multi_line)
.size_limit(1024 * 1024)
.dfa_size_limit(1024 * 1024)
.nest_limit(64)
.build()
.map_err(|_| "正则语法无效或复杂度超限,不支持前后顾与模式反向引用")
})
.transpose()?;
if matches!(
rule.action,
PrivacyAction::RegexReplace | PrivacyAction::RenameKey
) {
validate_replacement(&rule.replacement, regex.as_ref().ok_or("请填写匹配表达式")?)?;
}
if rule.action == PrivacyAction::SetValue && rule.pattern.is_some() {
return Err("固定值动作不使用匹配表达式");
}
Ok(Self {
rule: rule.clone(),
path,
regex,
})
}
pub(super) fn apply(
&self,
value: &mut Value,
budget: &mut Budget<'_>,
) -> Result<usize, &'static str> {
let mut count = 0;
let removed = selector::visit(value, &self.path, budget, &mut |value, budget| {
self.apply_value(value, budget, &mut count)
})?;
if removed {
return Err("不能删除作用范围根节点");
}
Ok(count)
}
pub(super) fn apply_value(
&self,
value: &mut Value,
budget: &mut Budget<'_>,
count: &mut usize,
) -> Result<bool, &'static str> {
budget.step()?;
match self.rule.action {
PrivacyAction::RemoveField => {
if let Some(regex) = &self.regex {
let text = value.as_str().ok_or("条件删除只能匹配字符串值")?;
budget.text(text)?;
if !regex.is_match(text) {
return Ok(false);
}
}
*count += 1;
return Ok(true);
}
PrivacyAction::SetValue => {
if std::mem::discriminant(value) != std::mem::discriminant(&self.rule.value) {
return Err("固定值必须保持原字段类型");
}
*value = self.rule.value.clone();
*count += 1;
}
PrivacyAction::RegexReplace => {
let text = value.as_str().ok_or("正则替换只能处理字符串")?;
let (replacement, matches) = self.replace(text, budget)?;
*value = Value::String(replacement);
*count += matches;
}
PrivacyAction::RenameKey => {
let object = value.as_object().ok_or("字段名替换只能处理对象")?;
let mut keys = BTreeSet::new();
let mut changes = Vec::new();
for key in object.keys() {
budget.step()?;
let (replacement, matches) = self.replace(key, budget)?;
if !keys.insert(replacement.clone()) {
return Err("字段名替换产生重名");
}
*count += matches;
changes.push(replacement);
}
let Value::Object(object) = std::mem::take(value) else {
return Err("目标类型已变化");
};
*value = Value::Object(changes.into_iter().zip(object.into_values()).collect());
}
}
Ok(false)
}
fn replace(
&self,
text: &str,
budget: &mut Budget<'_>,
) -> Result<(String, usize), &'static str> {
budget.text(text)?;
let regex = self.regex.as_ref().ok_or("缺少已编译表达式")?;
let mut output = String::new();
let mut end = 0;
let mut count = 0;
for captures in regex.captures_iter(text) {
budget.step()?;
let matched = captures.get(0).ok_or("缺少匹配结果")?;
output.push_str(&text[end..matched.start()]);
let largest = captures
.iter()
.flatten()
.map(|group| group.len())
.max()
.unwrap_or(0);
let references = self
.rule
.replacement
.bytes()
.filter(|byte| *byte == b'$')
.count();
if output
.len()
.saturating_add(self.rule.replacement.len())
.saturating_add(largest.saturating_mul(references))
> MAX_VALUE_BYTES
{
return Err("捕获组展开超出大小限制");
}
captures.expand(&self.rule.replacement, &mut output);
if output.len() > MAX_VALUE_BYTES {
return Err("替换结果超出大小限制");
}
end = matched.end();
count += 1;
if !self.rule.replace_all {
break;
}
}
output.push_str(&text[end..]);
if output.len() > MAX_VALUE_BYTES {
return Err("替换结果超出大小限制");
}
Ok((output, count))
}
}
fn validate_replacement(value: &str, regex: &Regex) -> Result<(), &'static str> {
let mut rest = value;
while let Some(index) = rest.find('$') {
rest = &rest[index + 1..];
if let Some(tail) = rest.strip_prefix('$') {
rest = tail;
continue;
}
let (name, tail) = if let Some(tail) = rest.strip_prefix('{') {
tail.split_once('}').ok_or("捕获组引用缺少 }")?
} else {
let length = rest
.bytes()
.take_while(|c| c.is_ascii_alphanumeric() || *c == b'_')
.count();
if length == 0 {
continue;
}
(&rest[..length], &rest[length..])
};
let valid = name.parse::<usize>().map_or_else(
|_| regex.capture_names().flatten().any(|group| group == name),
|index| index < regex.captures_len(),
);
if !valid {
return Err("替换内容引用了不存在的捕获组");
}
rest = tail;
}
Ok(())
}
@@ -0,0 +1,113 @@
//! 有界 JSON 字段选择器,仅支持成员、索引和单层通配
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Segment {
Key(String),
Index(usize),
Wildcard,
}
pub(super) fn parse(source: &str) -> Result<Vec<Segment>, &'static str> {
if source.len() > 1024 || !source.starts_with('$') {
return Err("字段路径应以 $ 开头且不超过 1024 字节");
}
let mut rest = &source[1..];
let mut segments = Vec::new();
while !rest.is_empty() {
if segments.len() == 16 {
return Err("字段路径最多 16 层");
}
if let Some(tail) = rest.strip_prefix('.') {
let end = tail.find(['.', '[']).unwrap_or(tail.len());
let key = &tail[..end];
segments.push(if key == "*" {
Segment::Wildcard
} else if !key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'))
{
Segment::Key(key.to_owned())
} else {
return Err("成员名包含特殊字符时使用双引号键名");
});
rest = &tail[end..];
} else if let Some(tail) = rest.strip_prefix('[') {
if tail.starts_with('"') {
let mut decoder = serde_json::Deserializer::from_str(tail).into_iter::<String>();
let key = decoder
.next()
.and_then(Result::ok)
.ok_or("无效的双引号键名")?;
rest = tail[decoder.byte_offset()..]
.strip_prefix(']')
.ok_or("字段路径缺少 ]")?;
segments.push(Segment::Key(key));
} else {
let (value, tail) = tail.split_once(']').ok_or("字段路径缺少 ]")?;
segments.push(if value == "*" {
Segment::Wildcard
} else if !value.is_empty() && value.bytes().all(|c| c.is_ascii_digit()) {
Segment::Index(value.parse().map_err(|_| "数组索引超出范围")?)
} else {
return Err("仅支持数组索引和 * 通配,不支持过滤表达式");
});
rest = tail;
}
} else {
return Err("无效的字段路径");
}
}
Ok(segments)
}
/// 从后向前访问数组,删除匹配项不会改变尚未访问的索引
pub(super) fn visit(
value: &mut Value,
segments: &[Segment],
budget: &mut super::Budget<'_>,
apply: &mut impl FnMut(&mut Value, &mut super::Budget<'_>) -> Result<bool, &'static str>,
) -> Result<bool, &'static str> {
budget.step()?;
let Some((first, rest)) = segments.split_first() else {
return apply(value, budget);
};
match (first, value) {
(Segment::Key(key), Value::Object(object)) => {
if let Some(child) = object.get_mut(key)
&& visit(child, rest, budget, apply)?
{
object.remove(key);
}
}
(Segment::Index(index), Value::Array(items)) => {
if let Some(child) = items.get_mut(*index)
&& visit(child, rest, budget, apply)?
{
items.remove(*index);
}
}
(Segment::Wildcard, Value::Object(object)) => {
let keys: Vec<_> = object.keys().cloned().collect();
for key in keys {
if let Some(child) = object.get_mut(&key)
&& visit(child, rest, budget, apply)?
{
object.remove(&key);
}
}
}
(Segment::Wildcard, Value::Array(items)) => {
for index in (0..items.len()).rev() {
if visit(&mut items[index], rest, budget, apply)? {
items.remove(index);
}
}
}
// 缺少字段属于正常未命中,已存在却不符合路径类型属于错误
_ => return Err("目标结构不符合字段路径"),
}
Ok(false)
}
@@ -26,6 +26,7 @@ const TURN_ID_CLIENT_METADATA_KEY: &str = "turn_id";
const THREAD_SPAWN_SUBAGENT_KIND: &str = "thread_spawn";
const THREAD_SPAWN_CONVERSATION_PREFIX: &str = "thread-spawn:";
const ENVIRONMENT_CONTEXT_CONTENT_KIND: &str = "environments.environment_context";
const DESKTOP_TIME_CONTEXT_CONTENT_KIND: &str = "additional_content.codex_apps_client_time_context";
const CROSS_ACCOUNT_IDENTITY_KEYS: &[&str] = &[
"authorization",
@@ -130,14 +131,14 @@ pub(crate) fn align_structured_location_fields(
now: DateTime<Utc>,
location: &CodexRequestLocation,
) {
// 只改写完整环境上下文的日期和时区;epoch 时间戳保持绝对时间原值
// 环境与 Desktop 时间上下文使用同一位置;epoch 时间戳保持绝对时间原值
let current_date = now
.with_timezone(&location.timezone)
.format("%Y-%m-%d")
.to_string();
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
for item in input {
align_environment_context(item, &current_date, location.timezone.name());
align_time_context(item, &current_date, location.timezone.name());
}
}
if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) {
@@ -147,13 +148,19 @@ pub(crate) fn align_structured_location_fields(
}
}
fn align_environment_context(item: &mut Value, current_date: &str, timezone: &str) {
fn align_time_context(item: &mut Value, current_date: &str, timezone: &str) {
let Some(item) = item.as_object_mut() else {
return;
};
if item.get("role").and_then(Value::as_str) != Some("user") {
return;
}
// Desktop 将客户端时间放入独立 developer 上下文,不能只覆盖执行环境的 user 消息
let (context_tag, content_kind) = match item.get("role").and_then(Value::as_str) {
Some("user") => ("environment_context", ENVIRONMENT_CONTEXT_CONTENT_KIND),
Some("developer") => (
"codex_apps_client_time_context",
DESKTOP_TIME_CONTEXT_CONTENT_KIND,
),
_ => return,
};
let content_kinds = item
.get("internal_chat_message_metadata_passthrough")
.and_then(Value::as_object)
@@ -169,10 +176,10 @@ fn align_environment_context(item: &mut Value, current_date: &str, timezone: &st
return;
};
for (index, part) in content.iter_mut().enumerate() {
// 官方自定义 Provider 会移除内容分类,缺省时仍按完整环境上下文识别
// 官方自定义 Provider 会移除内容分类,缺省时仍按角色与完整上下文识别
// 已有分类保持权威,不能覆盖显式标为普通文本或其他上下文的内容
if let Some(kinds) = &content_kinds
&& kinds.get(index).and_then(Option::as_deref) != Some(ENVIRONMENT_CONTEXT_CONTENT_KIND)
&& kinds.get(index).and_then(Option::as_deref) != Some(content_kind)
{
continue;
}
@@ -185,21 +192,27 @@ fn align_environment_context(item: &mut Value, current_date: &str, timezone: &st
let Some(Value::String(text)) = part.get_mut("text") else {
continue;
};
if let Some(aligned) = aligned_environment_context(text, current_date, timezone) {
if let Some(aligned) = aligned_time_context(text, context_tag, current_date, timezone) {
*text = aligned;
}
}
}
fn aligned_environment_context(text: &str, current_date: &str, timezone: &str) -> Option<String> {
fn aligned_time_context(
text: &str,
context_tag: &str,
current_date: &str,
timezone: &str,
) -> Option<String> {
let trimmed = text.trim();
if !trimmed.starts_with("<environment_context>") || !trimmed.ends_with("</environment_context>")
if !trimmed.starts_with(&format!("<{context_tag}>"))
|| !trimmed.ends_with(&format!("</{context_tag}>"))
{
return None;
}
let document = Document::parse(text).ok()?;
let root = document.root_element();
if !root.has_tag_name("environment_context") {
if !root.has_tag_name(context_tag) {
return None;
}
let mut replacements = root
@@ -599,6 +612,17 @@ fn metadata_string(request: &CodexResponsesRequest, key: &str) -> Option<String>
.map(ToOwned::to_owned)
}
pub(crate) fn serialize_ascii_turn_metadata(value: &Value) -> Option<String> {
let mut bytes = Vec::new();
value
.serialize(&mut serde_json::Serializer::with_formatter(
&mut bytes,
AsciiTurnMetadataFormatter,
))
.ok()?;
String::from_utf8(bytes).ok()
}
pub(crate) fn scope_turn_metadata(
raw: &str,
installation_id: &str,
@@ -635,14 +659,7 @@ pub(crate) fn scope_turn_metadata(
// Codex 的 turn metadata 同时承载于 HTTP header 与 WS client_metadata
// 改写安装 ID 后仍须保持官方 to_ascii_json_string 的编码合同;普通
// to_string 会把中文工作区路径还原成 UTF-8,触发上游 WS metadata 后 Close 1000
let mut bytes = Vec::new();
metadata
.serialize(&mut serde_json::Serializer::with_formatter(
&mut bytes,
AsciiTurnMetadataFormatter,
))
.ok()?;
String::from_utf8(bytes).ok()
serialize_ascii_turn_metadata(&Value::Object(metadata))
}
struct AsciiTurnMetadataFormatter;
@@ -9,12 +9,14 @@ use gateway_core::engine::response_control::{
use tokio::sync::{Mutex, mpsc, oneshot};
use tokio_tungstenite::tungstenite::{self, Message};
use super::CodexWebSocketExchangeError;
pub(super) const WEBSOCKET_SEND_TIMEOUT: Duration = Duration::from_secs(5 * 60);
pub(super) enum PumpCommand {
Send {
message: Message,
ack: oneshot::Sender<Result<(), tungstenite::Error>>,
ack: oneshot::Sender<Result<(), CodexWebSocketExchangeError>>,
},
}
@@ -28,15 +30,16 @@ pub(super) struct PumpControl {
pub(super) async fn send_message(
commands: &mpsc::Sender<PumpCommand>,
message: Message,
) -> Result<(), tungstenite::Error> {
) -> Result<(), CodexWebSocketExchangeError> {
let (ack, rx_ack) = oneshot::channel();
commands
.send(PumpCommand::Send { message, ack })
.await
.map_err(|_| tungstenite::Error::ConnectionClosed)?;
.map_err(|_| CodexWebSocketExchangeError::SendNotStarted)?;
// 回执丢失不能证明未发送,只有 pump 明确拒绝的命令才允许按 NotSent 恢复
rx_ack
.await
.unwrap_or(Err(tungstenite::Error::ConnectionClosed))
.unwrap_or_else(|_| Err(tungstenite::Error::ConnectionClosed.into()))
}
#[async_trait]
@@ -607,13 +607,17 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
}));
trace.capture("upstream.request.body", request.payload_text().as_bytes());
if let Err(error) = send_websocket_request(&websocket, request.payload_text()).await {
let observation = websocket
.observation()
.with_exit_reason("outbound_transport_error");
let not_started = matches!(error, CodexWebSocketExchangeError::SendNotStarted);
let observation = websocket.observation();
let observation = if observation.exit_reason() == "running" {
observation.with_exit_reason("outbound_transport_error")
} else {
observation
};
trace.record(
"upstream.send.failed",
serde_json::json!({
"phase": "websocket_payload", "sendState": "ambiguous",
"phase": "websocket_payload", "sendState": if not_started { "not_sent" } else { "ambiguous" },
"failureReason": error.transport_failure_reason().unwrap_or(observation.exit_reason()),
"connectionId": observation.connection_id().to_string(),
"connectionAgeMs": observation.age_ms(),
@@ -622,9 +626,16 @@ pub(crate) async fn execute_prepared_response_create_request_stream(
}),
);
discard_after_send(websocket, lease, observation.clone()).await;
return Err(post_send_ambiguous(
error.with_connection_observation(observation),
));
let error = if not_started {
if request.continuation().permits_fresh_connection() {
error
} else {
continuation_unavailable(PreviousResponseUnavailableReason::ReusedConnectionLost)
}
} else {
post_send_ambiguous(error)
};
return Err(error.with_connection_observation(observation));
}
trace.record("upstream.payload.sent", serde_json::json!({}));
let connection_local_available = lease.is_some();
@@ -44,6 +44,9 @@ pub enum CodexWebSocketExchangeError {
/// 同一精确会话的单飞建连失败
#[error("shared websocket connection attempt failed before payload send")]
SharedConnectFailed,
/// 连接已关闭或 pump 已退出,业务帧尚未交给底层发送
#[error("websocket connection closed before payload send started")]
SendNotStarted,
/// payload 已可能送达上游,禁止自动重放到其他 transport 或账号
#[error("websocket failed after payload send; replay outcome is ambiguous: {message}")]
PostSendAmbiguous {
@@ -2,7 +2,7 @@
//!
//! 每条上游 WebSocket 连接都由一个后台 pump 任务独占:
//! - 持续读取 socket:一旦观察到 `Close` / EOF / 传输错误,立即把连接标记为 `closed`
//! - 自动回应上游 `Ping`,并按 `ping_interval` 主动 `Ping`;收到任意入站帧即解除本次心跳 deadline
//! - 自动回应上游 `Ping`,并按 `ping_interval` 主动 `Ping`;收到非关闭入站帧即解除本次心跳 deadline
//! - 可选 `liveness_timeout`:长时间无任何入站活动时判定连接失活并退出
//!
//! 因此空闲连接的“死没死”在后台被实时感知;复用方只需零成本读取 [`PumpedWebSocket::is_closed`],
@@ -31,7 +31,7 @@ use tokio_tungstenite::tungstenite::Message;
use uuid::Uuid;
use super::{
CodexWebSocketCloseError,
CodexWebSocketCloseError, CodexWebSocketExchangeError,
control::{PumpCommand, PumpControl, SharedMessages, send_message},
};
@@ -342,8 +342,11 @@ impl PumpedWebSocket {
}
}
/// 通过 pump 发送一帧,返回底层 `send` 的结果
pub(crate) async fn send(&self, message: Message) -> Result<(), tungstenite::Error> {
/// 通过 pump 发送一帧,区分未开始发送与底层传输失败
pub(crate) async fn send(&self, message: Message) -> Result<(), CodexWebSocketExchangeError> {
if self.is_closed() {
return Err(CodexWebSocketExchangeError::SendNotStarted);
}
send_message(&self.tx_command, message).await
}
@@ -457,13 +460,12 @@ async fn pump_loop(
}
// 背压期间无法读取 socket,不能把这段本地下游阻塞误算成上游静默
last_activity = Instant::now();
record_activity(&lifecycle, last_activity);
}
command = rx_command.recv() => {
let Some(command) = command else {
break 'pump PumpExitReason::CommandChannelClosed;
};
if let Some(reason) = handle_command(&mut inner, command).await {
if let Some(reason) = handle_command(&mut inner, command, closed.load(Ordering::Acquire)).await {
break 'pump reason;
}
}
@@ -478,7 +480,7 @@ async fn pump_loop(
let Some(command) = command else {
break 'pump PumpExitReason::CommandChannelClosed;
};
if let Some(reason) = handle_command(&mut inner, command).await {
if let Some(reason) = handle_command(&mut inner, command, closed.load(Ordering::Acquire)).await {
break 'pump reason;
}
}
@@ -486,9 +488,12 @@ async fn pump_loop(
match message {
None => break 'pump PumpExitReason::UpstreamEof,
Some(Ok(message)) => {
last_activity = Instant::now();
record_activity(&lifecycle, last_activity);
observe_inbound(connection_id, &mut pending_ping, &message);
// Close 是终止信号,不能刷新链路健康时间或抹掉关闭前的静默时长
if !matches!(message, Message::Close(_)) {
last_activity = Instant::now();
record_activity(&lifecycle, last_activity);
observe_inbound(connection_id, &mut pending_ping, &message);
}
match message {
Message::Ping(payload) => {
if let Err(error) = inner.send(Message::Pong(payload)).await {
@@ -511,6 +516,9 @@ async fn pump_loop(
}),
_ => None,
};
if let Some(reason) = &terminal_reason {
record_closed(&closed, &lifecycle, reason);
}
if let Some(reason) = enqueue_inbound(
&tx_message,
&mut pending_inbound,
@@ -531,6 +539,7 @@ async fn pump_loop(
message: err.to_string(),
metric_reason: transport_metric_reason(&err),
};
record_closed(&closed, &lifecycle, &terminal_reason);
if let Some(reason) = enqueue_inbound(
&tx_message,
&mut pending_inbound,
@@ -571,15 +580,17 @@ async fn pump_loop(
}
};
closed.store(true, Ordering::Release);
record_closed(&closed, &lifecycle, &reason);
// 排队命令尚未进入底层发送,关闭握手前即回绝,避免调用方等待或误判为已发送
rx_command.close();
while let Ok(PumpCommand::Send { ack, .. }) = rx_command.try_recv() {
let _ = ack.send(Err(CodexWebSocketExchangeError::SendNotStarted));
}
let observation = {
let closed_at = Instant::now();
let mut lifecycle = lifecycle
let lifecycle = lifecycle
.lock()
.expect("WebSocket pump lifecycle lock poisoned");
lifecycle.closed_at = Some(closed_at);
lifecycle.exit_reason = Some(reason.clone());
lifecycle.observation(connection_id, closed_at)
lifecycle.observation(connection_id, Instant::now())
};
log_pump_exit(
connection_id,
@@ -620,15 +631,27 @@ fn enqueue_inbound(
}
}
async fn handle_command(inner: &mut RawWsStream, command: PumpCommand) -> Option<PumpExitReason> {
async fn handle_command(
inner: &mut RawWsStream,
command: PumpCommand,
closed: bool,
) -> Option<PumpExitReason> {
let PumpCommand::Send { message, ack } = command;
// 发送超时或取消后,尚在队列里的业务帧不应继续打到上游
if ack.is_closed() {
return None;
}
if closed {
let _ = ack.send(Err(CodexWebSocketExchangeError::SendNotStarted));
return None;
}
let is_close = matches!(message, Message::Close(_));
let result = inner.send(message).await;
let transport_error = result
.as_ref()
.err()
.map(|error| (error.to_string(), transport_metric_reason(error)));
let _ = ack.send(result);
let _ = ack.send(result.map_err(CodexWebSocketExchangeError::Transport));
match transport_error {
Some((message, metric_reason)) => Some(PumpExitReason::OutboundTransportError {
message,
@@ -662,6 +685,22 @@ fn record_activity(lifecycle: &Mutex<PumpLifecycleState>, observed_at: Instant)
.last_activity = observed_at;
}
fn record_closed(
closed: &AtomicBool,
lifecycle: &Mutex<PumpLifecycleState>,
reason: &PumpExitReason,
) {
let mut lifecycle = lifecycle
.lock()
.expect("WebSocket pump lifecycle lock poisoned");
// 先记录已观察到的终止事实,再通知复用方;入站背压不能延迟连接失效
if lifecycle.closed_at.is_none() {
lifecycle.closed_at = Some(Instant::now());
lifecycle.exit_reason = Some(reason.clone());
}
closed.store(true, Ordering::Release);
}
pub(crate) fn transport_metric_reason(error: &tungstenite::Error) -> &'static str {
match error {
tungstenite::Error::Io(error) => match error.kind() {
+28 -24
View File
@@ -2600,30 +2600,34 @@ async fn api_key_admin_exposes_only_configuration_and_preserves_key_when_rotatin
Some(&json!("https://first.example/v1"))
);
assert!(!configuration.contains_key("api_key"));
let prepared = admin
.prepare_rotation(PrepareCredentialRotation {
account: account_record(&account),
provider_material: ProviderDocument::new(OpaqueProviderData::new(
json!({"base_url":"https://second.example/root", "transport":"prefer_websocket"})
.as_object()
.unwrap()
.clone(),
)),
})
.await
.unwrap();
let material = prepared
.facts()
.provider_material
.expose_to_provider()
.expose_to_provider();
assert_eq!(material.get("api_key"), Some(&json!("sk-api-test-only")));
assert_eq!(
material.get("base_url"),
Some(&json!("https://second.example/root"))
);
assert!(!prepared.facts().has_refresh_token);
assert_eq!(prepared.facts().account_id, *account.id());
for base_url in [
"https://second.example/root",
"http://10.0.0.7:8080/v1",
"http://[fd00::7]:8080/v1",
"http://relay:8080/v1",
] {
let prepared = admin
.prepare_rotation(PrepareCredentialRotation {
account: account_record(&account),
provider_material: ProviderDocument::new(OpaqueProviderData::new(
json!({"base_url":base_url, "transport":"prefer_websocket"})
.as_object()
.unwrap()
.clone(),
)),
})
.await
.unwrap();
let material = prepared
.facts()
.provider_material
.expose_to_provider()
.expose_to_provider();
assert_eq!(material.get("api_key"), Some(&json!("sk-api-test-only")));
assert_eq!(material.get("base_url"), Some(&json!(base_url)));
assert!(!prepared.facts().has_refresh_token);
assert_eq!(prepared.facts().account_id, *account.id());
}
assert_eq!(
admin
.prepare_refresh(PrepareCredentialRefresh {
@@ -943,50 +943,73 @@ async fn api_key_import_export_preserves_target_without_oauth_exchange() {
runtime_policy(),
Arc::new(crate::RecordingDiagnostics::default()),
);
let imported = service.prepare_import_document_with_proxy(serde_json::json!({
"provider": "openai", "authentication_kind": "api_key", "name": "relay", "base_url": "https://relay.example/custom/v2", "api_key": "sk-test-only"
}), None).await.expect("import API account").into_accounts().pop().expect("one account");
assert_eq!(imported.account.authentication_kind(), "api_key");
assert!(!imported.account.has_refresh_token());
assert_eq!(imported.account.upstream_account_id(), None);
assert!(
!format!(
"{:?}",
CodexCredentialCodec::decode(&imported.credential).unwrap()
)
.contains("sk-test-only")
);
let now = Utc::now();
let document = CodexCredentialAdmin
.format_cpr_export(vec![ExportManagedCodexCredential {
current: LoadedCredential {
account: imported.account,
credential: imported.credential,
},
added_at: now,
updated_at: now,
}])
.expect("export")
.into_json()
.expect("JSON");
let restored = service
.prepare_import_document_with_proxy(document, None)
.await
.expect("reimport")
.into_accounts()
.pop()
.unwrap();
let CodexCredentialData::ApiKey(data) =
CodexCredentialCodec::decode_complete(&restored.credential).unwrap()
else {
panic!("API credential")
};
assert_eq!(data.base_url, "https://relay.example/custom/v2");
assert_eq!(data.api_key, "sk-test-only");
assert_eq!(
data.transport,
provider_openai::credential::ResponsesTransport::Http
);
for base_url in [
"https://relay.example/custom/v2",
"http://10.0.0.7:8080/v1",
"http://172.18.0.2:8080/v1",
"http://192.168.1.10:8080/v1",
"http://[fd00::7]:8080/v1",
"http://relay:8080/v1",
"http://remote.example/v1",
] {
let imported = service
.prepare_import_document_with_proxy(
serde_json::json!({
"provider": "openai",
"authentication_kind": "api_key",
"name": "relay",
"base_url": base_url,
"api_key": "sk-test-only"
}),
None,
)
.await
.expect("import API account")
.into_accounts()
.pop()
.expect("one account");
assert_eq!(imported.account.authentication_kind(), "api_key");
assert!(!imported.account.has_refresh_token());
assert_eq!(imported.account.upstream_account_id(), None);
assert!(
!format!(
"{:?}",
CodexCredentialCodec::decode(&imported.credential).unwrap()
)
.contains("sk-test-only")
);
let now = Utc::now();
let document = CodexCredentialAdmin
.format_cpr_export(vec![ExportManagedCodexCredential {
current: LoadedCredential {
account: imported.account,
credential: imported.credential,
},
added_at: now,
updated_at: now,
}])
.expect("export")
.into_json()
.expect("JSON");
let restored = service
.prepare_import_document_with_proxy(document, None)
.await
.expect("reimport")
.into_accounts()
.pop()
.unwrap();
let CodexCredentialData::ApiKey(data) =
CodexCredentialCodec::decode_complete(&restored.credential).unwrap()
else {
panic!("API credential")
};
assert_eq!(data.base_url, base_url);
assert_eq!(data.api_key, "sk-test-only");
assert_eq!(
data.transport,
provider_openai::credential::ResponsesTransport::Http
);
}
}
#[tokio::test]
@@ -998,7 +1021,10 @@ async fn api_key_import_rejects_unsafe_urls_and_empty_keys() {
Arc::new(crate::RecordingDiagnostics::default()),
);
for (url, key) in [
("http://remote.example/v1", "sk-test"),
("ftp://remote.example/v1", "sk-test"),
("http://user:password@relay:8080/v1", "sk-test"),
("http://relay:8080/v1?token=secret", "sk-test"),
("http://relay:8080/v1#fragment", "sk-test"),
("https://user:password@example.com", "sk-test"),
("https://example.com?token=secret", "sk-test"),
("https://example.com/#fragment", "sk-test"),
@@ -1025,6 +1051,9 @@ async fn sub2api_api_key_import_preserves_versioned_and_explicit_responses_paths
);
for (base, expected) in [
("https://example.com", "https://example.com/v1"),
("http://relay:8080", "http://relay:8080/v1"),
("http://10.0.0.7:8080/v1", "http://10.0.0.7:8080/v1"),
("http://relay:8080/v1/responses", "http://relay:8080/v1"),
(
"https://example.com/custom/v4",
"https://example.com/custom/v4",
@@ -488,6 +488,37 @@ async fn quota_refresh_synchronizes_plan_changes_without_losing_subtypes() {
}
}
#[tokio::test]
async fn passive_quota_observation_preserves_account_egress() {
let store = Arc::new(MemoryAccountStore::default());
let account_id = "acct_passive_quota_egress";
create_account(&store, account_id).await;
let proxy = gateway_core::account::OutboundProxy::parse("http://127.0.0.1:8080").unwrap();
let location = serde_json::from_value::<gateway_core::account::RequestLocation>(json!({
"country": "US", "region": "New York", "city": "New York",
"timezone": "America/New_York"
}))
.unwrap();
store.set_egress(account_id, Some(proxy.clone()), Some(location.clone()));
let account = store.account(account_id).unwrap();
let observation = parse_rate_limits_event(&json!({
"type": "codex.rate_limits",
"plan_type": "pro",
"rate_limits": {"primary": {"used_percent": 5, "window_minutes": 300, "reset_at": 1900000000}}
}))
.unwrap();
quota_service(&store)
.synchronize_passive_rate_limits(&account, &[observation])
.await
.unwrap();
let updated = store.account(account_id).unwrap();
assert!(store.quota_json(account_id).is_some());
assert_eq!(updated.outbound_proxy(), Some(&proxy));
assert_eq!(updated.request_location(), Some(&location));
}
#[tokio::test]
async fn passive_plan_observations_update_account_without_replaying_stale_plan() {
let store = Arc::new(MemoryAccountStore::default());
@@ -0,0 +1,317 @@
//! Images 的 OAuth 套餐资格、混合池、亲和和失败换号回归
use super::*;
use gateway_core::account::{AccountAffinity, ProviderAccountUpdate};
const PLUS: &str = "acct_provider_contract";
const FREE: [&str; 3] = ["acct_scope_new", "acct_scope_old", "acct_scope_same"];
async fn oauth_account(store: &MemoryAccountStore, id: &str, plan: Option<&str>) {
let mut verified_account = profile(&format!("chatgpt-{id}"));
verified_account.plan_type = plan.map(str::to_owned);
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: id.to_owned(),
name: id.to_owned(),
secret: secret(&format!("at-{id}")),
verified_account,
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
}
fn image(kind: ImageRequestKind, body: Value) -> Operation {
Operation::GenerateImage(ImageRequest::from_raw_json(
kind,
RawJsonPayload::new("openai", Bytes::from(serde_json::to_vec(&body).unwrap())).unwrap(),
))
}
fn selection_attempt(
accounts: AccountAttemptContext,
affinity: AccountAffinity,
index: u32,
) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_image_eligibility").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::new(index).unwrap(),
SystemTime::now() + Duration::from_secs(30),
account_policy().with_openai_account_affinity(affinity),
accounts.with_account_scope(contract_account_scope()),
None,
CancellationToken::new(),
)
}
#[tokio::test]
async fn images_use_plus_in_a_pool_with_three_higher_weight_free_accounts() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(header("chatgpt-account-id", format!("chatgpt-{PLUS}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]})))
.expect(2)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
oauth_account(&store, PLUS, Some("plus")).await;
for id in FREE {
oauth_account(&store, id, Some("free")).await;
store.set_scheduling(id, None, AccountWeight::new(100).unwrap());
}
let leases = Arc::new(TestLeaseCoordinator::default());
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
Arc::default(),
server.uri(),
leases.clone(),
);
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
let mut stream = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image(kind, json!({"model":"gpt-image-2","prompt":"test"})),
),
context("req_plus_images", CancellationToken::new()),
)
.await
.unwrap();
assert_eq!(stream.metadata().provider_account_id().as_str(), PLUS);
while let Some(event) = stream.next().await {
event.unwrap();
}
}
assert!(
leases
.requests
.lock()
.unwrap()
.iter()
.all(|request| request.account_id().as_str() == PLUS)
);
}
#[tokio::test]
async fn images_never_fall_back_to_free_when_plus_is_excluded_or_free_is_pinned() {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
oauth_account(&store, PLUS, Some("plus")).await;
oauth_account(&store, FREE[0], Some("free")).await;
let leases = Arc::new(TestLeaseCoordinator::default());
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
Arc::default(),
server.uri(),
leases.clone(),
);
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
for body in [json!({"model":"gpt-image-2"}), json!({"prompt":"test"})] {
for (required, excluded) in [
(
Some(ProviderAccountId::new(FREE[0]).unwrap()),
BTreeSet::new(),
),
(
None,
BTreeSet::from([ProviderAccountId::new(PLUS).unwrap()]),
),
] {
for index in [1, 2] {
let error = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", image(kind, body.clone())),
selection_attempt(
AccountAttemptContext::new(
excluded.clone(),
required.clone(),
None,
),
AccountAffinity::Strict,
index,
),
)
.await
.err()
.expect("Free must remain ineligible for Images");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
}
}
}
}
assert!(leases.requests.lock().unwrap().is_empty());
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn images_escape_free_session_bindings_while_search_remains_available() {
for mode in [
AccountAffinity::Strict,
AccountAffinity::Preferred,
AccountAffinity::Relaxed,
] {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
oauth_account(&store, PLUS, Some("plus")).await;
oauth_account(&store, FREE[0], Some("free")).await;
store.set_scheduling(FREE[0], None, AccountWeight::new(100).unwrap());
let affinity = Arc::new(MemorySessionAffinity::default());
let provider = provider_with_affinity_and_base_url(&store, affinity.clone(), server.uri());
let search = Operation::Search(StandaloneSearchRequest::from_raw_json(
RawJsonPayload::new(
"openai",
Bytes::from_static(br#"{"id":"image-plan-session","commands":{}}"#),
)
.unwrap(),
));
let stream = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", search),
selection_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None),
mode,
1,
),
)
.await
.unwrap();
assert_eq!(stream.metadata().provider_account_id().as_str(), FREE[0]);
drop(stream);
assert_eq!(affinity.binding_count(), 1);
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
let key = affinity.lookup_keys()[0].clone();
affinity.seed_binding(
&ProviderKind::new("openai").unwrap(),
&key,
ProviderAccountId::new(FREE[0]).unwrap(),
);
let stream = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image(
kind,
json!({
"model":"gpt-image-2", "session_id":"image-plan-session"
}),
),
),
selection_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None),
mode,
1,
),
)
.await
.unwrap();
assert_eq!(stream.metadata().provider_account_id().as_str(), PLUS);
drop(stream);
assert_eq!(affinity.binding_count(), 1);
}
assert!(server.received_requests().await.unwrap().is_empty());
}
}
#[tokio::test]
async fn image_plan_eligibility_tracks_account_updates_without_affecting_text() {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
oauth_account(&store, PLUS, Some("free")).await;
let provider = provider_with_base_url(&store, server.uri());
drop(
provider
.clone()
.execute(
planned_request("openai", generate_operation()),
context("req_free_text", CancellationToken::new()),
)
.await
.expect("Free can still use Responses"),
);
for plan in [
Some("free"),
Some("plus"),
Some("free"),
None,
Some("future-plan"),
] {
store
.update_account(ProviderAccountUpdate {
account_id: ProviderAccountId::new(PLUS).unwrap(),
name: PLUS.to_owned(),
email: None,
plan_type: plan.map(str::to_owned),
})
.await
.unwrap();
let result = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image(ImageRequestKind::Generation, json!({"model":"gpt-image-2"})),
),
context("req_plan_change", CancellationToken::new()),
)
.await;
if plan == Some("free") {
let error = result.err().expect("Free must be excluded");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
} else {
drop(result.expect("unknown plans are not inferred to be Free"));
}
}
}
#[tokio::test]
async fn api_key_image_accounts_do_not_inherit_chatgpt_free_plan_restrictions() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]})))
.expect(2)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
PLUS,
server.uri(),
provider_openai::credential::ResponsesTransport::Http,
)
.await;
store
.update_account(ProviderAccountUpdate {
account_id: ProviderAccountId::new(PLUS).unwrap(),
name: PLUS.to_owned(),
email: None,
plan_type: Some("free".to_owned()),
})
.await
.unwrap();
let provider = provider_with_base_url(&store, server.uri());
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
let mut stream = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image(kind, json!({"model":"gpt-image-2"})),
),
context("req_api_key_images", CancellationToken::new()),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
}
}
@@ -0,0 +1,461 @@
//! Images 混合账号池的模型权限、亲和、重试与中间件改写回归
use super::*;
use gateway_core::account::{AccountAffinity, AccountModelAccess, AccountModelAccessMode};
const CPA_ACCOUNT: &str = "acct_provider_contract";
const IMAGE_ACCOUNT: &str = "acct_scope_new";
const IMAGE_BODY: &[u8] =
br#"{ "model":"gpt-image-2", "prompt":"first", "prompt":"last", "future":9007199254740993 }"#;
fn policy(mode: AccountModelAccessMode, model: &str) -> AccountModelAccess {
AccountModelAccess::new(mode, vec![model.to_owned()]).unwrap()
}
fn scope(first: AccountModelAccess, second: AccountModelAccess) -> Arc<FrozenAccountScope> {
Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(
[(CPA_ACCOUNT, first), (IMAGE_ACCOUNT, second)]
.into_iter()
.map(|(id, policy)| {
(
ProviderAccountId::new(id).unwrap(),
RuntimeAccount::new(ProviderKind::new("openai").unwrap(), BTreeSet::new())
.with_model_access(policy),
)
})
.collect(),
)),
ClientRoutingScope::all_accounts(),
))
}
fn attempt(accounts: AccountAttemptContext) -> AttemptContext {
configured_attempt(accounts, AccountAffinity::Strict, 1, None)
}
fn configured_attempt(
accounts: AccountAttemptContext,
affinity: AccountAffinity,
index: u32,
middleware: Option<Arc<dyn MiddlewarePlan>>,
) -> AttemptContext {
let middleware = middleware.map(|plan| {
FrozenMiddlewarePlan::new(
plan,
ExtensionSetReference::new(
ExtensionSetId::new("image-access".to_owned()).unwrap(),
Arc::new(TestExtensionLease),
),
)
});
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_image_access").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
)
.with_middleware(
middleware,
Arc::from([]),
"/v1/images/generations".to_owned(),
ClientTransport::HttpJson,
),
NonZeroU32::new(index).unwrap(),
SystemTime::now() + Duration::from_secs(30),
account_policy().with_openai_account_affinity(affinity),
accounts,
None,
CancellationToken::new(),
)
}
#[tokio::test]
async fn images_do_not_use_forbidden_accounts_when_pinned_or_after_failover() {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, CPA_ACCOUNT).await;
create_account(&store, IMAGE_ACCOUNT).await;
let leases = Arc::new(TestLeaseCoordinator::default());
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
Arc::default(),
server.uri(),
leases.clone(),
);
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
for (required, excluded) in [
(
Some(ProviderAccountId::new(CPA_ACCOUNT).unwrap()),
BTreeSet::new(),
),
(
None,
BTreeSet::from([ProviderAccountId::new(IMAGE_ACCOUNT).unwrap()]),
),
] {
for index in [1, 2] {
let scope = scope(
policy(AccountModelAccessMode::Allowlist, "kimi-k3"),
policy(AccountModelAccessMode::Allowlist, "gpt-image-2"),
);
let error = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image_operation(kind, IMAGE_BODY),
),
configured_attempt(
AccountAttemptContext::new(excluded.clone(), required.clone(), None)
.with_account_scope(scope),
AccountAffinity::Strict,
index,
None,
),
)
.await
.err()
.expect("forbidden account must not be used");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
}
}
}
assert!(leases.requests.lock().unwrap().is_empty());
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn unresolved_image_models_only_use_unrestricted_accounts_without_rewriting_bodies() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]})))
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, CPA_ACCOUNT).await;
create_account(&store, IMAGE_ACCOUNT).await;
store.set_scheduling(CPA_ACCOUNT, None, AccountWeight::new(100).unwrap());
let provider = provider_with_base_url(&store, server.uri());
let bodies: &[&[u8]] = &[
br#"{"prompt":"default model"}"#,
br#"{"model":null}"#,
br#"{"model":{ "future":"unknown" }}"#,
br#"{"model":""}"#,
br#"{"model":"gpt-image-2","model":"forbidden"}"#,
br#"{"model":"forbidden","model":"gpt-image-2"}"#,
br#"{"model":"gpt-image-2", broken"#,
b"--image-boundary\r\nopaque multipart body\r\n--image-boundary--\r\n",
];
for mode in [
AccountModelAccessMode::Allowlist,
AccountModelAccessMode::Denylist,
] {
for body in bodies {
for unrestricted in [true, false] {
let scope = scope(
policy(mode, "gpt-image-2"),
if unrestricted {
AccountModelAccess::all()
} else {
policy(mode, "gpt-image-2")
},
);
let result = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image_operation(ImageRequestKind::Edit, body),
),
attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(scope),
),
)
.await;
if unrestricted {
let mut stream = result.expect("unrestricted account accepts opaque payloads");
assert_eq!(
stream.metadata().provider_account_id().as_str(),
IMAGE_ACCOUNT
);
while let Some(event) = stream.next().await {
event.unwrap();
}
assert_eq!(
server
.received_requests()
.await
.unwrap()
.last()
.unwrap()
.body,
*body
);
} else {
let error = result
.err()
.expect("unknown model cannot bypass either model policy");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
}
}
}
}
assert_eq!(
server.received_requests().await.unwrap().len(),
bodies.len() * 2
);
}
#[tokio::test]
async fn image_model_access_overrides_existing_session_affinity() {
for mode in [
AccountAffinity::Strict,
AccountAffinity::Preferred,
AccountAffinity::Relaxed,
] {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, CPA_ACCOUNT).await;
create_account(&store, IMAGE_ACCOUNT).await;
store.set_scheduling(CPA_ACCOUNT, None, AccountWeight::new(100).unwrap());
let affinity = Arc::new(MemorySessionAffinity::default());
let provider = provider_with_affinity_and_base_url(&store, affinity.clone(), server.uri());
let operation = image_operation(
ImageRequestKind::Generation,
br#"{"model":"gpt-image-2","session_id":"image-model-session","prompt":"test"}"#,
);
let stream = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", operation.clone()),
configured_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None).with_account_scope(
scope(AccountModelAccess::all(), AccountModelAccess::all()),
),
mode,
1,
None,
),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
CPA_ACCOUNT
);
drop(stream);
let stream = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", operation),
configured_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None).with_account_scope(
scope(
policy(AccountModelAccessMode::Allowlist, "kimi-k3"),
policy(AccountModelAccessMode::Allowlist, "gpt-image-2"),
),
),
mode,
1,
None,
),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
IMAGE_ACCOUNT
);
assert!(server.received_requests().await.unwrap().is_empty());
}
}
#[tokio::test]
async fn final_image_model_after_middleware_must_be_allowed_by_the_selected_account() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]})))
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, CPA_ACCOUNT).await;
let provider = provider_with_base_url(&store, server.uri());
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
for (replacement, allowed) in [
(json!("forbidden"), false),
(Value::Null, false),
(json!("gpt-image-future"), true),
] {
let scope = scope(
AccountModelAccess::new(
AccountModelAccessMode::Allowlist,
vec!["gpt-image-2".to_owned(), "gpt-image-future".to_owned()],
)
.unwrap(),
AccountModelAccess::all(),
);
let middleware = Arc::new(RecordingMiddleware {
observed: Arc::default(),
replacement: ("model".to_owned(), replacement.clone()),
request_headers: Vec::new(),
});
let result = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", image_operation(kind, IMAGE_BODY)),
configured_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(scope),
AccountAffinity::Strict,
1,
Some(middleware),
),
)
.await;
if allowed {
let mut stream = result.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
assert_eq!(
captured_request_body(
server.received_requests().await.unwrap().last().unwrap()
)["model"],
replacement
);
} else {
let error = result
.err()
.expect("rewriting cannot escape account policy");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert!(error.retry_is_prohibited());
}
}
}
assert_eq!(server.received_requests().await.unwrap().len(), 2);
}
#[tokio::test]
async fn standalone_search_does_not_inherit_image_model_restrictions() {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, CPA_ACCOUNT).await;
let search = Operation::Search(StandaloneSearchRequest::from_raw_json(
RawJsonPayload::new(
"openai",
Bytes::from_static(br#"{"id":"search","commands":{}}"#),
)
.unwrap(),
));
let stream = provider_with_base_url(&store, server.uri())
.execute(
planned_provider_endpoint_request("openai", search),
attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None).with_account_scope(scope(
policy(AccountModelAccessMode::Allowlist, "kimi-k3"),
AccountModelAccess::all(),
)),
),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
CPA_ACCOUNT
);
}
fn image_operation(kind: ImageRequestKind, body: &[u8]) -> Operation {
Operation::GenerateImage(ImageRequest::from_raw_json(
kind,
RawJsonPayload::new("openai", Bytes::copy_from_slice(body)).unwrap(),
))
}
#[tokio::test]
async fn images_filter_mixed_accounts_by_model_before_weight_and_preserve_wire() {
let cpa = MockServer::start().await;
let oauth = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
CPA_ACCOUNT,
cpa.uri(),
provider_openai::credential::ResponsesTransport::Http,
)
.await;
store.set_scheduling(CPA_ACCOUNT, None, AccountWeight::new(100).unwrap());
create_account(&store, IMAGE_ACCOUNT).await;
Mock::given(method("POST"))
.and(body_bytes(IMAGE_BODY.to_vec()))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data":[{"b64_json":"AAEC"}]})),
)
.expect(4)
.mount(&oauth)
.await;
let leases = Arc::new(TestLeaseCoordinator::default());
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
Arc::default(),
oauth.uri(),
leases.clone(),
);
for cpa_policy in [
policy(AccountModelAccessMode::Allowlist, "kimi-k3"),
policy(AccountModelAccessMode::Denylist, "gpt-image-2"),
] {
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
let scope = scope(
cpa_policy.clone(),
policy(AccountModelAccessMode::Allowlist, "gpt-image-2"),
);
let mut stream = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", image_operation(kind, IMAGE_BODY)),
attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(scope),
),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
IMAGE_ACCOUNT
);
while let Some(event) = stream.next().await {
event.unwrap();
}
}
}
assert!(cpa.received_requests().await.unwrap().is_empty());
assert!(
leases
.requests
.lock()
.unwrap()
.iter()
.all(|request| request.account_id().as_str() == IMAGE_ACCOUNT)
);
assert_eq!(
oauth
.received_requests()
.await
.unwrap()
.iter()
.map(|request| request.url.path().to_owned())
.collect::<Vec<_>>(),
[
"/codex/images/generations",
"/codex/images/edits",
"/codex/images/generations",
"/codex/images/edits"
]
);
}
@@ -3,8 +3,11 @@
mod account_isolation;
mod capacity;
mod error_details;
mod image_account_eligibility;
mod image_model_access;
mod passthrough;
mod precommit;
mod privacy;
mod response_interrupt;
mod session_binding;
mod timing;
@@ -775,7 +778,9 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c
let original = json!({"model":"gpt-5.4", "input":[
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["environments.environment_context"], "create_time":1789293131.822}},
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text"]}},
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}]}
{"role":"user", "content":[{"type":"input_text", "text":"<environment_context><timezone>UTC</timezone></environment_context>"}]},
{"role":"developer", "content":[{"type":"input_text", "text":"<codex_apps_client_time_context><timezone>UTC</timezone></codex_apps_client_time_context>"}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["additional_content.codex_apps_client_time_context"]}},
{"role":"developer", "content":[{"type":"input_text", "text":"<codex_apps_client_time_context><timezone>UTC</timezone></codex_apps_client_time_context>"}]}
], "tools":[{"type":"web_search"}]});
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", original.as_object().unwrap().clone())
@@ -800,6 +805,8 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c
assert_eq!(body.pointer("/input/0/content/0/text"), Some(&json!(format!("<environment_context><timezone>{expected}</timezone></environment_context>"))));
assert_eq!(body.pointer("/input/1/content/0/text"), original.pointer("/input/1/content/0/text"));
assert_eq!(body.pointer("/input/2/content/0/text"), body.pointer("/input/0/content/0/text"));
assert_eq!(body.pointer("/input/3/content/0/text"), Some(&json!(format!("<codex_apps_client_time_context><timezone>{expected}</timezone></codex_apps_client_time_context>"))));
assert_eq!(body.pointer("/input/4/content/0/text"), body.pointer("/input/3/content/0/text"));
assert_eq!(body.pointer("/input/0/internal_chat_message_metadata_passthrough/create_time"), Some(&json!(1789293131.822)));
}
assert_eq!(first_proxy.received_requests().await.unwrap().len(), 2);
@@ -1879,7 +1886,7 @@ async fn provider_should_send_the_request_snapshot_location_to_the_upstream() {
}
#[tokio::test]
async fn provider_should_override_unclassified_environment_timezone_over_websocket() {
async fn provider_should_override_unclassified_time_contexts_over_websocket() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
@@ -1896,11 +1903,15 @@ async fn provider_should_override_unclassified_environment_timezone_over_websock
body
});
let environment = "<environment_context><cwd>/home/example/项目</cwd><timezone>Asia/Shanghai</timezone></environment_context>";
let desktop = "<codex_apps_client_time_context><timezone>Asia/Shanghai</timezone></codex_apps_client_time_context>";
let payload = ProtocolPayload::json_object(
"openai",
json!({
"model": "gpt-5.4",
"input": [{"type": "message", "role": "user", "content": [{"type": "input_text", "text": environment}]}],
"input": [
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": environment}]},
{"type": "message", "role": "developer", "content": [{"type": "input_text", "text": desktop}]}
],
"tools": [{"type": "web_search"}]
}).as_object().expect("request object").clone(),
)
@@ -1920,6 +1931,10 @@ async fn provider_should_override_unclassified_environment_timezone_over_websock
event.expect("successful websocket response");
}
let body = server.await.expect("server");
assert_eq!(
body.pointer("/input/1/content/0/text"),
Some(&json!(desktop.replace("Asia/Shanghai", "Pacific/Auckland")))
);
assert_eq!(
body.pointer("/input/0/content/0/text"),
Some(&json!(
@@ -4205,7 +4220,9 @@ async fn abrupt_websocket_disconnect_preserves_diagnosis_and_ambiguous_send_stat
assert_eq!(error.kind(), ProviderErrorKind::Transport);
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert!(!error.replay_is_safe());
assert_eq!(error.pre_delivery_retry(), None);
assert!(
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, .. }) if retry_index.get() == 1)
);
assert_eq!(
error.diagnostic().map(|diagnostic| diagnostic.as_str()),
Some(
@@ -4714,7 +4731,9 @@ async fn ambiguous_websocket_close_reconnects_and_retains_native_continuation()
assert!(saw_websocket_observation);
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert_eq!(error.pre_delivery_retry(), None);
assert!(
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, .. }) if retry_index.get() == 1)
);
assert!(error.upstream_code().is_none());
let close: Value = serde_json::from_str(error.raw_upstream_error().unwrap().as_str()).unwrap();
assert_eq!(close["type"], "websocket.close");
@@ -4930,7 +4949,17 @@ async fn repeated_websocket_failures_exhaust_budget_then_use_http_and_report_htt
}
};
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert_eq!(error.pre_delivery_retry(), None);
if index < 2 {
assert!(matches!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransportRetry { .. })
));
} else {
assert_eq!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransportFallback)
);
}
assert!(!error.replay_is_safe());
}
for index in 0..2 {
@@ -5935,7 +5964,7 @@ async fn websocket_turn_state_metadata_is_exposed_through_response_observation()
}
#[tokio::test]
async fn websocket_turn_state_metadata_close_does_not_authorize_replay() {
async fn websocket_metadata_close_requests_bounded_retry_without_claiming_replay_safety() {
const ACCOUNT_ID: &str = "acct_websocket_metadata_close";
const TURN_STATE: &str = "turn-state-before-close";
@@ -6017,7 +6046,9 @@ async fn websocket_turn_state_metadata_close_does_not_authorize_replay() {
assert!(session_update.is_none());
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert!(!error.replay_is_safe());
assert_eq!(error.pre_delivery_retry(), None);
assert!(
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, .. }) if retry_index.get() == 1)
);
assert!(error.upstream_code().is_none());
let close: Value = serde_json::from_str(error.raw_upstream_error().unwrap().as_str()).unwrap();
assert_eq!(close["type"], "websocket.close");
@@ -9044,7 +9075,7 @@ async fn ordinary_request_should_hold_created_until_later_failure_can_rotate() {
}
#[tokio::test]
async fn ordinary_request_should_bound_structural_event_replay_grace() {
async fn ordinary_request_should_hold_structural_events_until_upstream_finishes() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_bounded_replay_grace").await;
let (base_url, release, _first_chunk_sent, server) = paused_chunked_sse_server(
@@ -9064,29 +9095,24 @@ async fn ordinary_request_should_bound_structural_event_replay_grace() {
.await
.expect("prepare provider stream");
let first_event = timeout(Duration::from_secs(4), async {
loop {
let event = stream
.next()
.await
.expect("provider stream must stay open")
.expect("provider event");
if event.has_client_event() {
return event;
assert!(
timeout(Duration::from_secs(3), async {
while let Some(event) = stream.next().await {
assert!(!event.expect("provider event").has_client_event());
}
}
})
.await
.expect("response.created must be released after the bounded grace period");
assert_eq!(
first_event.wire_event().and_then(|wire| wire.event_type()),
Some("response.created")
})
.await
.is_err()
);
release.send(()).expect("finish upstream response");
while let Some(event) = stream.next().await {
event.expect("clean upstream EOF");
}
let events = stream.collect::<Vec<_>>().await;
assert!(events.iter().any(|event| {
event
.as_ref()
.unwrap()
.wire_event()
.is_some_and(|wire| wire.event_type() == Some("response.created"))
}));
server.await.expect("chunked SSE server");
}
@@ -9578,7 +9604,7 @@ async fn exact_websocket_busy_then_replay_scope_relaxation_is_rejected_before_se
}
#[tokio::test]
async fn continuation_prefetch_over_128_kib_should_wait_for_grace_without_protocol_failure() {
async fn continuation_prefetch_over_128_kib_stays_buffered_until_eof_without_protocol_failure() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_prefetch_limit").await;
let padding = "x".repeat(128 * 1024);
@@ -9623,11 +9649,17 @@ async fn continuation_prefetch_over_128_kib_should_wait_for_grace_without_protoc
timeout(Duration::from_secs(1), next_visible.as_mut())
.await
.is_err(),
"large structural events must remain buffered during the grace period"
"large structural events must remain buffered before semantic output"
);
timeout(Duration::from_secs(3), next_visible)
assert!(
timeout(Duration::from_secs(3), next_visible.as_mut())
.await
.is_err()
);
release.send(()).expect("finish upstream response");
timeout(Duration::from_secs(1), next_visible)
.await
.expect("grace expiry must release buffered wire")
.expect("EOF releases original wire")
};
assert_eq!(
@@ -9640,7 +9672,6 @@ async fn continuation_prefetch_over_128_kib_should_wait_for_grace_without_protoc
.raw_sse_frame()
.unwrap()
.to_vec();
release.send(()).expect("finish upstream response");
while let Some(event) = stream.next().await {
if let Some(frame) = event
.expect("clean upstream EOF cannot become a protocol failure")
@@ -11656,7 +11687,7 @@ async fn quota_continuation_stream_rejection_only_projects_before_delivery() {
}
#[tokio::test]
async fn quota_continuation_after_structural_commit_preserves_original_failure() {
async fn late_quota_continuation_rejection_requires_client_replay_before_output() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let original = json!({"type":"response.failed","response":{"id":"resp_quota","error":{"code":"usage_limit_reached","message":"limit reached"}}});
@@ -11672,16 +11703,15 @@ async fn quota_continuation_after_structural_commit_preserves_original_failure()
)
.await
.unwrap();
timeout(Duration::from_secs(3), async {
while let Some(event) = stream.next().await {
if event.unwrap().has_client_event() {
return;
assert!(
timeout(Duration::from_secs(3), async {
while let Some(event) = stream.next().await {
assert!(!event.unwrap().has_client_event());
}
}
panic!("expected grace commit");
})
.await
.expect("bounded grace");
})
.await
.is_err()
);
release.send(()).unwrap();
let mut delivered_failure = false;
let error = loop {
@@ -11695,9 +11725,9 @@ async fn quota_continuation_after_structural_commit_preserves_original_failure()
None => panic!("expected failure"),
}
};
assert!(delivered_failure);
assert!(!error.replay_is_safe());
assert_ne!(
assert!(!delivered_failure);
assert!(error.replay_is_safe());
assert_eq!(
error.continuation_recovery_disposition(),
Some(ContinuationRecoveryDisposition::ClientReplayRequired)
);
@@ -1,4 +1,4 @@
//! 验证结构事件缓冲、交付宽限与取消对重试边界的影响
//! 验证结构事件缓冲、长时间等待与取消对重试边界的影响
use gateway_core::diagnostics::TraceContext;
use gateway_core::engine::provider::ProviderStream;
@@ -16,7 +16,7 @@ fn traced_context(trace: &TraceContext, cancellation: CancellationToken) -> Atte
)
.with_trace(trace.clone()),
NonZeroU32::new(1).unwrap(),
SystemTime::now() + Duration::from_secs(30),
SystemTime::now() + Duration::from_secs(3_600),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
@@ -127,7 +127,7 @@ async fn large_structural_events_preserve_overload_replay_on_http_and_websocket(
.unwrap();
assert!(
timeout(Duration::from_millis(1_500), next_client_event(&mut stream))
timeout(Duration::from_millis(3_500), next_client_event(&mut stream))
.await
.is_err()
);
@@ -156,12 +156,11 @@ async fn large_structural_events_preserve_overload_replay_on_http_and_websocket(
}
#[tokio::test]
async fn later_structural_events_do_not_extend_grace_and_late_overload_is_not_replayable() {
async fn late_overload_after_multiple_structural_events_remains_replayable() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let created = sse(&structural_event("response.created", 96 * 1024));
let progress = sse(&structural_event("response.in_progress", 96 * 1024));
let expected_bytes = created.len() + progress.len();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let (release, released) = oneshot::channel();
@@ -184,30 +183,30 @@ async fn later_structural_events_do_not_extend_grace_and_late_overload_is_not_re
)
.await
.unwrap();
let first = timeout(Duration::from_millis(3_500), next_client_event(&mut stream))
.await
.unwrap();
assert_eq!(
first.wire_event().unwrap().event_type(),
Some("response.created")
assert!(
timeout(Duration::from_millis(3_500), next_client_event(&mut stream))
.await
.is_err()
);
release.send(()).unwrap();
let (events, error) = timeout(Duration::from_secs(1), stream_failure(&mut stream))
let (events, mut error) = timeout(Duration::from_secs(1), stream_failure(&mut stream))
.await
.unwrap();
assert!(!error.replay_is_safe());
assert!(error.pre_delivery_retry().is_none());
assert!(events.iter().any(|event| {
event
.wire_event()
.is_some_and(|wire| wire.data() == &overload())
}));
let releases = releases(&trace);
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["attemptIndex"], 1);
assert_eq!(releases[0]["data"]["reason"], "grace_timeout");
assert_eq!(releases[0]["data"]["prefetchedBytes"], expected_bytes);
assert!((2_500..3_500).contains(&releases[0]["data"]["waitMs"].as_u64().unwrap()));
assert!(events.iter().all(|event| !event.has_client_event()));
assert!(error.replay_is_safe());
assert!(matches!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransientRetry { .. })
));
assert_eq!(
error
.take_atomic_client_events()
.iter()
.filter_map(|event| event.wire_event()?.event_type())
.collect::<Vec<_>>(),
["response.created", "response.in_progress", "error"]
);
assert!(releases(&trace).is_empty());
server.await.unwrap();
}
@@ -311,3 +310,164 @@ async fn cancelling_buffered_structural_events_does_not_release_or_offer_replay(
release.send(()).unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn twenty_minute_structural_progress_preserves_late_overload_recovery() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let (progress, mut progress_requests) = tokio::sync::mpsc::channel::<()>(1);
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
read_http_request(&mut socket).await;
socket.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ntransfer-encoding: chunked\r\n\r\n").await.unwrap();
write_http_chunk(
&mut socket,
&sse(&structural_event("response.created", 1024)),
)
.await;
while progress_requests.recv().await.is_some() {
write_http_chunk(
&mut socket,
&sse(&structural_event("response.in_progress", 1024)),
)
.await;
}
write_http_chunk(&mut socket, &sse(&overload())).await;
socket.write_all(b"0\r\n\r\n").await.unwrap();
});
let trace = TraceContext::new("req_precommit");
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", http_generate_operation()),
traced_context(&trace, CancellationToken::new()),
)
.await
.unwrap();
assert!(
timeout(Duration::from_millis(100), next_client_event(&mut stream))
.await
.is_err()
);
// 结构进度保持上游传输活跃,长思考不能额外触发首内容等待期限
for _ in 0..20 {
tokio::time::pause();
tokio::time::advance(Duration::from_secs(60)).await;
tokio::time::resume();
progress.send(()).await.unwrap();
assert!(
timeout(Duration::from_millis(50), next_client_event(&mut stream))
.await
.is_err()
);
}
drop(progress);
let (events, error) = timeout(Duration::from_secs(1), stream_failure(&mut stream))
.await
.unwrap();
assert!(events.iter().all(|event| !event.has_client_event()));
assert!(error.replay_is_safe());
assert!(matches!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransientRetry { .. })
));
assert!(releases(&trace).is_empty());
server.await.unwrap();
}
#[tokio::test]
async fn structural_preamble_over_16_mib_preserves_late_overload_recovery() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
// 大段配置回显仍属于前导事件,累计体积不应改变上游失败的恢复方式
let preamble = sse(&structural_event("response.in_progress", 1024 * 1024)).repeat(17);
let (base_url, release, _, server) =
paused_chunked_sse_server(preamble, sse(&overload())).await;
let trace = TraceContext::new("req_precommit");
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", http_generate_operation()),
traced_context(&trace, CancellationToken::new()),
)
.await
.unwrap();
assert!(
timeout(Duration::from_secs(1), next_client_event(&mut stream))
.await
.is_err()
);
release.send(()).unwrap();
let (events, mut error) = timeout(Duration::from_secs(10), stream_failure(&mut stream))
.await
.unwrap();
assert!(events.iter().all(|event| !event.has_client_event()));
assert!(error.replay_is_safe());
assert!(matches!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransientRetry { .. })
));
let buffered_bytes: usize = error
.take_atomic_client_events()
.iter()
.filter_map(|event| event.wire_event()?.raw_sse_frame())
.map(bytes::Bytes::len)
.sum();
assert!(buffered_bytes > 16 * 1024 * 1024);
assert!(releases(&trace).is_empty());
server.await.unwrap();
}
#[tokio::test]
async fn websocket_disconnect_recovery_honors_request_retry_budget_without_session_affinity() {
for max_retries in [0, 5] {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
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 {
for _ in 0..=max_retries {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket(socket).await;
ws.next().await.unwrap().unwrap();
ws.send(Message::Text(
structural_event("response.created", 1024)
.to_string()
.into(),
))
.await
.unwrap();
ws.close(None).await.unwrap();
}
});
let provider = provider_with_base_url_and_retry_budget(&store, base_url, max_retries);
for retry_count in 0..=max_retries {
let transport = NonZeroU32::new(retry_count)
.map_or(AttemptTransport::Default, AttemptTransport::Retry);
let trace = TraceContext::new("req_precommit");
let mut stream = Arc::clone(&provider)
.execute(
planned_request("openai", generate_operation()),
traced_context(&trace, CancellationToken::new()).with_transport(transport),
)
.await
.unwrap();
let (events, error) = stream_failure(&mut stream).await;
assert!(events.iter().all(|event| !event.has_client_event()));
assert_eq!(error.send_state(), UpstreamSendState::Ambiguous);
assert!(!error.replay_is_safe());
if retry_count < max_retries {
assert!(
matches!(error.pre_delivery_retry(), Some(PreDeliveryRetry::SameAccountTransportRetry { retry_index, .. }) if retry_index.get() == retry_count + 1)
);
} else {
assert_eq!(
error.pre_delivery_retry(),
Some(PreDeliveryRetry::SameAccountTransportFallback)
);
}
assert!(releases(&trace).is_empty());
}
server.await.unwrap();
}
}
@@ -0,0 +1,331 @@
//! 隐私规则在真实 HTTP、WebSocket 和发送前拒绝边界的合同
use super::*;
use crate::transport::privacy::{policy, rule};
use gateway_core::settings::privacy::{CodexPrivacyPolicy, PrivacyAction, PrivacyScope};
fn privacy_context(policy: &CodexPrivacyPolicy, fallback: bool) -> AttemptContext {
let context = AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_privacy").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
)
.with_privacy(Some(
provider_openai::transport::privacy::compile(policy).unwrap(),
)),
NonZeroU32::MIN,
SystemTime::now() + Duration::from_secs(30),
account_policy(),
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(contract_account_scope()),
None,
CancellationToken::new(),
);
if fallback {
context.with_transport(AttemptTransport::Fallback)
} else {
context
}
}
fn outbound_policy() -> CodexPrivacyPolicy {
let mut auth = rule(
PrivacyScope::RequestHeader,
"authorization",
PrivacyAction::SetValue,
);
auth.value = json!("Bearer configured-synthetic-token");
let mut input = rule(
PrivacyScope::RequestBody,
"$.input[0].content[0].text",
PrivacyAction::RegexReplace,
);
input.id = "input".into();
input.pattern = Some("hello".into());
input.replacement = "hellohello".into();
let mut metadata = rule(
PrivacyScope::TurnMetadata,
"$.workspaces",
PrivacyAction::RemoveField,
);
metadata.id = "metadata".into();
policy(vec![auth, input, metadata])
}
fn metadata_operation() -> Operation {
let header =
json!({"workspaces":{"/home/alex/private":{}},"request_kind":"turn","header_only":true});
let client = json!({"workspaces":{"/home/alex/private":{}},"tool_namespaces_info":{"tools":["example"]}});
let body = json!({"model":"gpt-5.4","input":"hello","turnMetadata":header.to_string(),"client_metadata":{"x-codex-turn-metadata":client.to_string()}});
Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", body.as_object().unwrap().clone()).unwrap(),
))
}
fn assert_metadata_filtered(raw: &str, client_copy: bool) {
let metadata: Value = serde_json::from_str(raw).unwrap();
assert!(metadata.get("workspaces").is_none());
if client_copy {
assert_eq!(
metadata["tool_namespaces_info"],
json!({"tools":["example"]})
);
assert!(metadata.get("header_only").is_none());
} else {
assert_eq!(metadata["header_only"], true);
assert!(metadata.get("tool_namespaces_info").is_none());
}
}
#[tokio::test]
async fn privacy_json_endpoints_preserve_untouched_bytes_and_rewrite_matched_body() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"output":"ok"})))
.mount(&server)
.await;
let provider = provider_with_base_url(&store, server.uri());
let raw = Bytes::from_static(
br#"{ "id":"search", "workspaces":{"/private":{}}, "future":1, "future":2 }"#,
);
for change_body in [false, true] {
let mut config = outbound_policy();
config.rules.truncate(1);
if change_body {
let mut remove = rule(
PrivacyScope::RequestBody,
"$.workspaces",
PrivacyAction::RemoveField,
);
remove.id = "remove-workspace".into();
config.rules.push(remove);
}
let operation = Operation::Search(StandaloneSearchRequest::from_raw_json(
RawJsonPayload::new("openai", raw.clone()).unwrap(),
));
let mut stream = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", operation),
privacy_context(&config, false),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
let requests = server.received_requests().await.unwrap();
let sent = requests.last().unwrap();
assert_eq!(
sent.headers["authorization"],
"Bearer configured-synthetic-token"
);
if change_body {
assert_eq!(
serde_json::from_slice::<Value>(&sent.body).unwrap(),
json!({"id":"search","future":2})
);
} else {
assert_eq!(sent.body, raw);
}
}
}
#[tokio::test]
async fn privacy_rewrites_final_http_headers_and_body_once_per_attempt() {
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),
)
.mount(&server)
.await;
let provider = provider_with_base_url(&store, server.uri());
let operation = metadata_operation();
for _ in 0..2 {
let mut stream = provider
.clone()
.execute(
planned_request("openai", operation.clone()),
privacy_context(&outbound_policy(), true),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
}
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 2);
for request in requests {
assert_metadata_filtered(
request.headers["x-codex-turn-metadata"].to_str().unwrap(),
false,
);
assert_metadata_filtered(
captured_request_body(&request)["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.unwrap(),
true,
);
assert_eq!(
request.headers["authorization"],
"Bearer configured-synthetic-token"
);
assert_eq!(
captured_request_body(&request)["input"][0]["content"][0]["text"],
"hellohello"
);
}
}
#[tokio::test]
async fn privacy_rejection_is_not_sent_and_cannot_retry_or_rotate() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let server = MockServer::start().await;
let mut invalid_target = rule(
PrivacyScope::RequestBody,
"$.input[0].content[0].text",
PrivacyAction::SetValue,
);
invalid_target.value = json!(42);
let provider = provider_with_base_url(&store, server.uri());
let result = provider
.execute(
planned_request("openai", generate_operation()),
privacy_context(&policy(vec![invalid_target]), true),
)
.await;
let error = match result {
Err(error) => error,
Ok(mut stream) => stream.next().await.unwrap().err().unwrap(),
};
assert_eq!(error.kind(), ProviderErrorKind::InvalidRequest);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert!(error.retry_is_prohibited());
assert!(server.received_requests().await.unwrap().is_empty());
}
#[tokio::test]
async fn privacy_rewrites_websocket_handshake_and_frame_once() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let captured = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket =
crate::transport::accept_codex_test_websocket_with(stream, |request, response| {
assert_eq!(
request.headers()["authorization"],
"Bearer configured-synthetic-token"
);
assert_metadata_filtered(
request.headers()["x-codex-turn-metadata"].to_str().unwrap(),
false,
);
response.headers_mut().insert(
"sec-websocket-extensions",
"permessage-deflate".parse().unwrap(),
);
})
.await;
let frame = socket.next().await.unwrap().unwrap().into_text().unwrap();
let body: Value = serde_json::from_str(&frame).unwrap();
assert_metadata_filtered(
body["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.unwrap(),
true,
);
assert_eq!(body["input"][0]["content"][0]["text"], "hellohello");
socket.send(Message::Text(json!({"type":"response.completed","response":{"id":"resp_privacy","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}).to_string().into())).await.unwrap();
});
let provider = provider_with_base_url(&store, format!("http://{address}"));
let mut stream = provider
.execute(
planned_request("openai", metadata_operation()),
privacy_context(&outbound_policy(), false),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
captured.await.unwrap();
}
#[tokio::test]
async fn privacy_header_change_uses_a_new_websocket_pool_connection() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let captured = tokio::spawn(async move {
let mut sockets = Vec::new();
for expected in [
"Bearer first-synthetic-token",
"Bearer second-synthetic-token",
] {
let (stream, _) = timeout(Duration::from_secs(5), listener.accept())
.await
.unwrap()
.unwrap();
let mut socket =
crate::transport::accept_codex_test_websocket_with(stream, |request, response| {
assert_eq!(request.headers()["authorization"], expected);
response.headers_mut().insert(
"sec-websocket-extensions",
"permessage-deflate".parse().unwrap(),
);
})
.await;
let frame = socket.next().await.unwrap().unwrap().into_text().unwrap();
assert_eq!(
serde_json::from_str::<Value>(&frame).unwrap()["input"][0]["content"][0]["text"],
"hellohello"
);
socket.send(Message::Text(json!({"type":"response.completed","response":{"id":"resp_privacy_pool","model":"gpt-5.4","status":"completed","output":[]}}).to_string().into())).await.unwrap();
// 保留第一条连接,确保下一次握手是池隔离的结果而非断线重连
sockets.push(socket);
}
sockets
});
let provider = provider_with_base_url(&store, format!("http://{address}"));
for token in [
"Bearer first-synthetic-token",
"Bearer second-synthetic-token",
] {
let mut config = outbound_policy();
config.rules[0].value = json!(token);
let operation = Operation::Generate(generate_with_session_context(
"privacy-pool",
Some("privacy-thread"),
None,
));
let mut stream = provider
.clone()
.execute(
planned_request("openai", operation),
privacy_context(&config, false),
)
.await
.unwrap();
timeout(Duration::from_secs(5), async {
while let Some(event) = stream.next().await {
event.unwrap();
}
})
.await
.unwrap();
}
captured.await.unwrap();
}
@@ -3,6 +3,158 @@
use super::*;
use gateway_core::engine::response_control::{ResponseControl, ResponseControlUnavailable};
#[tokio::test]
async fn cancelled_queued_control_is_not_forwarded_to_upstream() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
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 ws = accept_codex_test_websocket(socket).await;
assert_eq!(next_json(&mut ws).await["type"], "response.create");
ws.send(Message::Text(
json!({
"type":"response.output_text.delta", "output_index":0,
"content_index":0, "delta":"partial"
})
.to_string()
.into(),
))
.await
.unwrap();
let received = next_json(&mut ws).await;
assert_eq!(
received["marker"], "active",
"cancelled command must never reach upstream"
);
});
let control = ResponseControl::default();
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", generate_operation()),
interrupt_context(control.clone(), None),
)
.await
.unwrap();
loop {
if stream.next().await.unwrap().unwrap().has_client_event() {
break;
}
}
{
// 单线程 runtime 中只入队一次,未让 pump 执行便取消调用方
let mut cancelled =
Box::pin(control.send(r#"{"type":"future.control","marker":"cancelled"}"#));
assert!(futures::poll!(cancelled.as_mut()).is_pending());
}
timeout(
Duration::from_secs(2),
control.send(r#"{"type":"future.control","marker":"active"}"#),
)
.await
.unwrap()
.unwrap();
timeout(Duration::from_secs(2), server)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn controls_reject_closed_socket_without_losing_backpressured_close() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_provider_contract").await;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let (burst, ready) = oneshot::channel();
let (closed, closing) = oneshot::channel();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket(socket).await;
next_json(&mut ws).await;
let delta = Message::Text(
json!({
"type":"response.output_text.delta", "output_index":0,
"content_index":0, "delta":"partial"
})
.to_string()
.into(),
);
ws.send(delta.clone()).await.unwrap();
ready.await.unwrap();
// 填满 exchange 的 16 帧和 pump 的 64 帧,并占住正在转发的一帧
for _ in 0..81 {
ws.send(delta.clone()).await.unwrap();
}
// Pong 证明 pump 已读取前面的突发数据,随后 Close 必须等待下游排空
ws.send(Message::Ping(vec![1].into())).await.unwrap();
assert!(matches!(
ws.next().await.unwrap().unwrap(),
Message::Pong(_)
));
ws.close(Some(CloseFrame {
code: CloseCode::Normal,
reason: "".into(),
}))
.await
.unwrap();
closed.send(()).unwrap();
// 保留 TCP 直到客户端确认关闭,避免把 Close 场景变成连接重置
let _ = ws.next().await;
});
let control = ResponseControl::default();
let mut stream = provider_with_base_url(&store, base_url)
.execute(
planned_request("openai", generate_operation()),
interrupt_context(control.clone(), None),
)
.await
.unwrap();
loop {
if stream.next().await.unwrap().unwrap().has_client_event() {
break;
}
}
burst.send(()).unwrap();
timeout(Duration::from_secs(2), closing)
.await
.unwrap()
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
timeout(
Duration::from_secs(2),
control.send(r#"{"type":"response.interrupt"}"#)
)
.await
.unwrap(),
Err(ResponseControlUnavailable)
);
let (delivered, error) = timeout(Duration::from_secs(2), async {
let mut delivered = 0;
loop {
match stream.next().await.unwrap() {
Ok(event) => delivered += usize::from(event.has_client_event()),
Err(error) => break (delivered, error),
}
}
})
.await
.unwrap();
assert_eq!(delivered, 81);
assert_eq!(error.diagnostic().unwrap().code(), Some("upstream_close"));
assert!(error.diagnostic().unwrap().as_str().contains("1000"));
assert!(
error.pre_delivery_retry().is_none(),
"delivered output cannot be replayed"
);
timeout(Duration::from_secs(2), server)
.await
.unwrap()
.unwrap();
}
async fn next_json(socket: &mut tokio_tungstenite::WebSocketStream<TcpStream>) -> Value {
loop {
match socket.next().await.unwrap().unwrap() {
@@ -665,6 +665,8 @@ fn rebuild_account(current: &ProviderAccount, rebuild: AccountRebuild) -> Provid
)
.with_scheduling(current.concurrency_limit(), current.weight())
.with_refresh_schedule(rebuild.has_refresh_token, rebuild.next_refresh_at)
.with_outbound_proxy(current.outbound_proxy().cloned())
.with_request_location(current.request_location().cloned())
}
#[derive(Default)]
@@ -58,6 +58,7 @@ mod endpoints;
mod headers;
mod http_client;
mod latency;
pub(crate) mod privacy;
mod profile;
mod profile_avatar;
mod profile_contract;
@@ -0,0 +1,385 @@
//! 隐私规则的多承载一致性、自由字段配置、条件删除与有界失败合同
use gateway_core::settings::privacy::*;
use provider_openai::transport::privacy::{compile, preview};
use serde_json::{Value, json};
pub(crate) fn rule(scope: PrivacyScope, selector: &str, action: PrivacyAction) -> PrivacyRule {
PrivacyRule {
id: "rule-1".into(),
name: "测试规则".into(),
enabled: true,
scope,
selector: selector.into(),
action,
pattern: None,
replacement: String::new(),
value: Value::Null,
replace_all: true,
case_insensitive: false,
multi_line: false,
}
}
pub(crate) fn policy(rules: Vec<PrivacyRule>) -> CodexPrivacyPolicy {
CodexPrivacyPolicy {
enabled: true,
on_error: PrivacyFailureMode::RejectRequest,
rules,
}
}
fn sample(rules: Vec<PrivacyRule>, body: Value) -> PrivacyPreviewRequest {
PrivacyPreviewRequest {
policy: policy(rules),
body,
headers: Default::default(),
turn_metadata: None,
}
}
#[test]
fn privacy_removes_workspaces_from_every_copy_without_merging_metadata() {
let workspaces = json!({"/home/alex/项目": {"associated_remote_urls": {"origin":"https://example.test/private/repo"}}});
let header = json!({"workspaces":workspaces,"request_kind":"primary"});
let client = json!({"workspaces":workspaces,"tool_namespaces_info":{"tools":["a"]}});
let mut request = sample(
vec![rule(
PrivacyScope::TurnMetadata,
"$.workspaces",
PrivacyAction::RemoveField,
)],
json!({
"turnMetadata": header.to_string(), "turn_metadata": header, "x-codex-turn-metadata": header,
"client_metadata": {"x-codex-turn-metadata":client.to_string()}
}),
);
request.turn_metadata = Some(header.to_string());
request.headers.insert(
"x-codex-turn-metadata".into(),
vec![header.to_string(), header.to_string()],
);
let output = preview(request).unwrap();
assert_eq!(output.outcomes[0].matches, 7);
assert_eq!(
output.turn_metadata.as_deref(),
Some("{\"request_kind\":\"primary\"}")
);
assert_eq!(
output.body["turn_metadata"],
json!({"request_kind":"primary"})
);
assert_eq!(
serde_json::from_str::<Value>(
output.body["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.unwrap()
)
.unwrap(),
json!({"tool_namespaces_info":{"tools":["a"]}})
);
assert!(
output.headers["x-codex-turn-metadata"]
.iter()
.all(|raw| !raw.contains("workspaces"))
);
}
#[test]
fn privacy_conditionally_deletes_values_and_renames_path_keys_in_sequence() {
let mut remove = rule(
PrivacyScope::RequestBody,
"$.workspaces.*.associated_remote_urls.*",
PrivacyAction::RemoveField,
);
remove.pattern = Some("/private-org/".into());
let mut rename = rule(
PrivacyScope::RequestBody,
"$.workspaces",
PrivacyAction::RenameKey,
);
rename.id = "rename".into();
rename.pattern = Some("^/home/[^/]+/(.*)$".into());
rename.replacement = "/workspace/${1}".into();
let output = preview(sample(vec![remove, rename], json!({"workspaces":{
"/home/alex/demo":{"associated_remote_urls":{"origin":"https://host/private-org/demo","upstream":"https://host/public/demo"}}
}}))).unwrap();
assert_eq!(
output.body,
json!({"workspaces":{"/workspace/demo":{"associated_remote_urls":{"upstream":"https://host/public/demo"}}}})
);
let mut remove = rule(
PrivacyScope::RequestBody,
"$.remotes[*]",
PrivacyAction::RemoveField,
);
remove.pattern = Some("private".into());
assert_eq!(
preview(sample(
vec![remove],
json!({"remotes":["private-a","public","private-b","private-c"]})
))
.unwrap()
.body,
json!({"remotes":["public"]})
);
}
#[test]
fn privacy_has_no_field_protection_list_in_any_scope() {
for name in [
"authorization",
"cookie",
"chatgpt-account-id",
"x-codex-turn-state",
"x-codex-installation-id",
] {
let mut request = sample(
vec![rule(
PrivacyScope::RequestHeader,
name,
PrivacyAction::RemoveField,
)],
json!({}),
);
request
.headers
.insert(name.into(), vec!["synthetic-value".into()]);
assert!(preview(request).unwrap().headers.is_empty(), "{name}");
}
for name in [
"session_id",
"thread_id",
"previous_response_id",
"prompt_cache_key",
"tool_namespaces_info",
"history_ingest_requested",
"analytics_enabled",
] {
let output = preview(sample(
vec![rule(
PrivacyScope::RequestBody,
&format!("$.{name}"),
PrivacyAction::RemoveField,
)],
json!({name:"synthetic"}),
))
.unwrap();
assert_eq!(output.body, json!({}), "{name}");
}
}
#[test]
fn privacy_rolls_back_all_copies_of_a_failed_rule_and_continues_in_order() {
let mut first = rule(
PrivacyScope::TurnMetadata,
"$.workspace",
PrivacyAction::RegexReplace,
);
first.pattern = Some("private".into());
first.replacement = "public".into();
let mut second = rule(PrivacyScope::RequestBody, "$.keep", PrivacyAction::SetValue);
second.id = "second".into();
second.value = json!("done");
let body = json!({"turnMetadata":{"workspace":"private"},"client_metadata":{"turnMetadata":{"workspace":42}},"keep":"start"});
let mut request = sample(vec![first.clone(), second], body.clone());
request.policy.on_error = PrivacyFailureMode::SkipRule;
let output = preview(request).unwrap();
assert_eq!(output.body["turnMetadata"], body["turnMetadata"]);
assert_eq!(output.body["keep"], "done");
assert_eq!(output.outcomes[0].status, "skipped");
assert_eq!(output.outcomes[1].status, "applied");
assert_eq!(
preview(sample(vec![first], body)).err().unwrap().rule_index,
0
);
}
#[test]
fn privacy_key_collisions_fail_without_losing_values() {
let mut rename = rule(
PrivacyScope::RequestBody,
"$.workspaces",
PrivacyAction::RenameKey,
);
rename.pattern = Some(".+".into());
rename.replacement = "same".into();
let body = json!({"workspaces":{"first":1,"second":2}});
let mut request = sample(vec![rename], body.clone());
request.policy.on_error = PrivacyFailureMode::SkipRule;
let output = preview(request).unwrap();
assert_eq!(output.body, body);
assert_eq!(output.outcomes[0].status, "skipped");
}
#[test]
fn privacy_desktop_context_is_classified_and_reencoded_without_touching_prose() {
let text = "<external_codex_git_state><environment_context><git source=\"app\" trust=\"untrusted\">{\"workspaces\":{\"/private\":{}},\"note\":\"a &amp; b\"}</git></environment_context></external_codex_git_state>";
let mut replacement = rule(
PrivacyScope::DesktopGitContext,
"$.workspaces",
PrivacyAction::RemoveField,
);
let body = json!({"input":[{"role":"user","content":[{"type":"input_text","text":text},{"type":"input_text","text":text}],"internal_chat_message_metadata_passthrough":{"content_item_kinds":["additional_content.codex_git_state","user.text"]}}]});
let output = preview(sample(vec![replacement.clone()], body)).unwrap();
assert!(
!output.body["input"][0]["content"][0]["text"]
.as_str()
.unwrap()
.contains("workspaces")
);
assert!(
output.body["input"][0]["content"][0]["text"]
.as_str()
.unwrap()
.contains("a &amp; b")
);
assert_eq!(output.body["input"][0]["content"][1]["text"], text);
replacement.scope = PrivacyScope::EnvironmentText;
replacement.selector = "$".into();
replacement.action = PrivacyAction::RegexReplace;
replacement.pattern = Some("Asia/Shanghai".into());
replacement.replacement = "UTC".into();
let body = json!({"input":[{"role":"developer","content":[{"type":"input_text","text":"<codex_apps_client_time_context><timezone>Asia/Shanghai</timezone></codex_apps_client_time_context>"}]}]});
assert!(
preview(sample(vec![replacement], body))
.unwrap()
.body
.to_string()
.contains("<timezone>UTC</timezone>")
);
}
#[test]
fn privacy_validates_regex_paths_captures_and_execution_limits() {
let mut replace = rule(
PrivacyScope::RequestBody,
"$[\"a.b\"][0]",
PrivacyAction::RegexReplace,
);
replace.pattern = Some("(?P<word>a)".into());
replace.replacement = "${word}${1}$$".into();
assert_eq!(
preview(sample(vec![replace.clone()], json!({"a.b":["a"]})))
.unwrap()
.body,
json!({"a.b":["aa$"]})
);
replace.replacement = "${missing}".into();
assert!(compile(&policy(vec![replace.clone()])).is_err());
replace.replacement.clear();
replace.pattern = Some("(?<=a)b".into());
assert!(compile(&policy(vec![replace])).is_err());
for path in ["$..secret", "$[?(@.secret)]", "$['single']"] {
assert!(
compile(&policy(vec![rule(
PrivacyScope::RequestBody,
path,
PrivacyAction::RemoveField
)]))
.is_err()
);
}
let policy = compile(&policy(vec![rule(
PrivacyScope::RequestBody,
"$.items.*.missing",
PrivacyAction::RemoveField,
)]))
.unwrap();
let mut body = json!({"items":vec![json!({}); 10_001]});
let original = body.clone();
let error = policy
.apply(&mut body, &mut Default::default(), &mut None, &|| false)
.unwrap_err();
assert_eq!(error.reason, "规则执行预算超限");
assert_eq!(body, original);
assert!(
policy
.apply(&mut json!({}), &mut Default::default(), &mut None, &|| true)
.is_err()
);
}
#[test]
fn privacy_disabled_policy_and_absent_fields_preserve_original_bytes() {
let body = json!({"turnMetadata":"{ \"keep\" : 1 }"});
let request = sample(
vec![rule(
PrivacyScope::TurnMetadata,
"$.workspaces",
PrivacyAction::RemoveField,
)],
body.clone(),
);
assert_eq!(preview(request.clone()).unwrap().body, body);
let mut request = request;
request.policy.enabled = false;
assert_eq!(preview(request).unwrap().outcomes[0].status, "disabled");
}
#[test]
fn privacy_context_parser_handles_quoted_brackets_and_bounds_xml_depth() {
let text = "<external_codex_git_state><environment_context><git source=\"a>b\"><![CDATA[{\"workspaces\":{},\"note\":\"a & b\"}]]></git></environment_context></external_codex_git_state>";
let body = json!({"input":[{"role":"user","content":[{"type":"input_text","text":text}]}]});
let output = preview(sample(
vec![rule(
PrivacyScope::DesktopGitContext,
"$.workspaces",
PrivacyAction::RemoveField,
)],
body,
))
.unwrap();
let changed = output.body["input"][0]["content"][0]["text"]
.as_str()
.unwrap();
let document = roxmltree::Document::parse(changed).unwrap();
let git = document
.descendants()
.find(|node| node.has_tag_name("git"))
.unwrap();
assert_eq!(git.attribute("source"), Some("a>b"));
assert_eq!(
serde_json::from_str::<Value>(git.text().unwrap()).unwrap(),
json!({"note":"a & b"})
);
let nested = format!(
"<environment_context>{}value{}</environment_context>",
"<n>".repeat(100),
"</n>".repeat(100)
);
let mut replace = rule(
PrivacyScope::EnvironmentText,
"$",
PrivacyAction::RegexReplace,
);
replace.pattern = Some("value".into());
replace.replacement = "changed".into();
let body = json!({"input":[{"role":"user","content":[{"type":"input_text","text":nested}]}]});
assert_eq!(
preview(sample(vec![replace], body)).err().unwrap().reason,
"上下文 XML 嵌套超过 64 层"
);
}
#[test]
fn privacy_reports_headers_that_websocket_opening_cannot_serialize() {
let mut replace = rule(
PrivacyScope::RequestHeader,
"x-label",
PrivacyAction::SetValue,
);
replace.value = json!("别名");
let mut request = sample(vec![replace], json!({}));
request
.headers
.insert("x-label".into(), vec!["original".into()]);
assert_eq!(
preview(request.clone()).err().unwrap().reason,
"替换结果必须是 ASCII 请求头值"
);
request.policy.on_error = PrivacyFailureMode::SkipRule;
let result = preview(request).unwrap();
assert_eq!(result.headers["x-label"], vec!["original"]);
assert_eq!(result.outcomes[0].status, "skipped");
}
@@ -158,6 +158,102 @@ fn encoder_should_align_official_environment_context_without_content_kinds() {
}
}
#[test]
fn encoder_should_align_desktop_time_context_with_environment_dates() {
// Desktop Core 0.162.0-alpha.17.2 的应用附加上下文使用 developer role
let desktop = " <codex_apps_client_time_context><timezone>Asia/Shanghai</timezone>\n\
<current_date>2020-01-01</current_date>\n\
Use this client time context for user-facing dates, times, and schedules instead of the execution host's timezone and current date.\
</codex_apps_client_time_context>\n";
let environment = "<environment_context><current_date>2020-01-01</current_date><timezone>Asia/Shanghai</timezone></environment_context>";
for kinds in [
None,
Some(json!([
"developer.text",
"additional_content.codex_apps_client_time_context"
])),
] {
let mut message = json!({
"type": "message", "role": "developer",
"content": [
{"type": "input_text", "text": "Keep Asia/Shanghai in this instruction"},
{"type": "input_text", "text": desktop}
],
"internal_chat_message_metadata_passthrough": {"create_time": 1789293131.822}
});
if let Some(kinds) = kinds {
message["internal_chat_message_metadata_passthrough"]["content_item_kinds"] = kinds;
}
let body = json!({"input": [
{"role": "user", "content": [{"type": "input_text", "text": environment}]},
message
]})
.as_object()
.expect("request object")
.clone();
let before = Utc::now()
.with_timezone(&New_York)
.format("%Y-%m-%d")
.to_string();
let encoded = encode_generate_request(
&request(body.clone()),
"gpt-test",
Some(&CodexRequestLocation::default()),
)
.expect("encode Desktop time context");
let after = Utc::now()
.with_timezone(&New_York)
.format("%Y-%m-%d")
.to_string();
assert!(
[before, after].iter().any(|date| {
let mut expected = body["input"].clone();
expected[0]["content"][0]["text"] = json!(
environment
.replace("2020-01-01", date)
.replace("Asia/Shanghai", "America/New_York")
);
expected[1]["content"][1]["text"] = json!(
desktop
.replace("2020-01-01", date)
.replace("Asia/Shanghai", "America/New_York")
);
encoded.body().get("input") == Some(&expected)
}),
"both time contexts must agree while preserving other content and metadata"
);
let unchanged = encode_generate_request(&request(body.clone()), "gpt-test", None)
.expect("encode without override");
assert_eq!(unchanged.body().get("input"), body.get("input"));
}
}
#[test]
fn encoder_should_preserve_text_outside_desktop_time_context() {
let desktop = "<codex_apps_client_time_context><timezone>Asia/Shanghai</timezone><current_date>2020-01-01</current_date></codex_apps_client_time_context>";
let input = json!([
{"role": "user", "content": [{"type": "input_text", "text": desktop}]},
{"role": "assistant", "content": [{"type": "output_text", "text": desktop}]},
{"type": "function_call_output", "call_id": "synthetic", "output": desktop},
{"role": "developer", "content": [{"type": "input_text", "text": desktop}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["user.text"]}},
{"role": "developer", "content": [{"type": "input_text", "text": desktop}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["environments.environment_context"]}},
{"role": "developer", "content": [{"type": "input_text", "text": desktop}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": []}},
{"role": "developer", "content": [{"type": "input_text", "text": format!("Example:\n{desktop}")}]},
{"role": "developer", "content": [{"type": "input_text", "text": format!("{desktop}\nKeep this example")}]},
{"role": "developer", "content": [{"type": "input_text", "text": "<codex_apps_client_time_context><timezone>Asia/Shanghai</codex_apps_client_time_context>"}]},
{"role": "developer", "content": [{"type": "input_text", "text": "<other_context><timezone>Asia/Shanghai</timezone></other_context>"}]},
{"role": "developer", "content": [{"type": "output_text", "text": desktop}]},
{"role": "developer", "content": [{"type": "input_text", "text": "<codex_apps_client_time_context><example><timezone>Asia/Shanghai</timezone><current_date>2020-01-01</current_date></example></codex_apps_client_time_context>"}]}
]);
let encoded = encode_generate_request(
&request(Map::from_iter([("input".to_owned(), input.clone())])),
"gpt-test",
Some(&CodexRequestLocation::default()),
)
.expect("encode without changing unrelated content");
assert_eq!(encoded.body().get("input"), Some(&input));
}
#[test]
fn encoder_should_preserve_classified_text_and_non_user_environment_items() {
let environment =
@@ -3,6 +3,131 @@
use super::*;
use gateway_core::diagnostics::TraceContext;
#[tokio::test]
async fn closed_connection_handoff_rejects_payload_as_not_sent() {
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 ws = accept_codex_test_websocket(socket).await;
ws.close(None).await.unwrap();
// pump 的关闭确认是 best-effort;无业务帧的 EOF 同样能证明连接已退出
assert!(
matches!(
ws.next().await,
Some(Ok(Message::Close(_)))
| Some(Err(tungstenite::Error::Protocol(
tungstenite::error::ProtocolError::ResetWithoutClosingHandshake
)))
),
"no business payload may arrive after the observed close"
);
});
let trace = TraceContext::new("req_closed_handoff");
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
request.set_previous_response_id(Some("resp_previous".to_owned()));
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
request.local_conversation_id = Some("closed-handoff".to_owned());
request.force_http_sse = false;
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
)
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
let pending = backend.create_response_stream(
&request,
request_context("req_closed_handoff", Some("chatgpt-account")).with_trace(&trace),
);
tokio::pin!(pending);
// 停在后台建连交接处,等 pump 确认关闭后才恢复业务请求,确定性覆盖检查与发送间的窗口
assert!(futures::poll!(pending.as_mut()).is_pending());
timeout(Duration::from_secs(2), server)
.await
.unwrap()
.unwrap();
let error = pending
.await
.err()
.expect("closed handoff must reject send");
assert!(matches!(error, CodexClientError::WebSocket(
CodexWebSocketExchangeError::ConnectionObserved { source, .. }
) if matches!(*source, CodexWebSocketExchangeError::SendNotStarted)));
let snapshot = trace.snapshot().unwrap();
let events = snapshot["events"].as_array().unwrap();
let failure = events
.iter()
.find(|event| event["stage"] == "upstream.send.failed")
.unwrap();
assert_eq!(failure["data"]["sendState"], "not_sent");
assert_eq!(failure["data"]["failureReason"], "upstream_close");
assert!(
!events
.iter()
.any(|event| event["stage"] == "upstream.payload.sent")
);
}
#[tokio::test]
async fn close_observation_preserves_idle_time_since_last_inbound_activity() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (close, closed) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
let (socket, _) = listener.accept().await.unwrap();
let mut ws = accept_codex_test_websocket(socket).await;
ws.next().await.unwrap().unwrap();
ws.send(Message::Text(
json!({
"type": "response.output_text.delta", "delta": "initial"
})
.to_string()
.into(),
))
.await
.unwrap();
closed.await.unwrap();
ws.close(Some(tokio_tungstenite::tungstenite::protocol::CloseFrame {
code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Normal,
reason: "".into(),
}))
.await
.unwrap();
});
let trace = TraceContext::new("req_idle_close");
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
request.set_previous_response_id(Some("resp_previous".to_owned()));
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
request.force_http_sse = false;
let backend = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
format!("http://{addr}"),
test_wire_profile(),
);
let mut response = backend
.create_response_stream(
&request,
request_context("req_idle_close", Some("chatgpt-account")).with_trace(&trace),
)
.await
.unwrap();
response.body.next().await.unwrap().unwrap();
tokio::time::sleep(Duration::from_millis(80)).await;
close.send(()).unwrap();
let error = response.body.next().await.unwrap().unwrap_err();
let CodexClientError::WebSocket(error) = error else {
panic!("expected websocket close");
};
assert_eq!(error.close_before_terminal().unwrap().code(), Some(1000));
let observation = error.connection_observation().unwrap();
assert_eq!(observation.exit_reason(), "normal_close");
assert!(
observation.idle_ms() >= 80,
"Close must not reset idle time"
);
server.await.unwrap();
}
#[tokio::test]
async fn diagnostics_preserve_abrupt_disconnect_facts_without_response_content() {
for (reuse, send_event) in [(false, false), (false, true), (true, false), (true, true)] {

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