Compare commits

...
6 Commits
Author SHA1 Message Date
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
114 changed files with 4607 additions and 558 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)
@@ -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)
}
}
+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,
@@ -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(),
@@ -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)?,
@@ -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,
@@ -890,6 +890,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 +1122,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",
@@ -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,
@@ -376,6 +376,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 +1026,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;
+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",
@@ -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!(
@@ -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();
}
@@ -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 =
+1
View File
@@ -23,3 +23,4 @@ e8c1339166ac02fb2d4a077424ee5f64f6dc20eb4d3f8ec4fcb09c0f2a0e61f6 0020_plugin_tr
d431ebf24669451da06bb6671136bf58cddcc713b43a1a705eb33f9cb4f9b577 0023_error_details_and_account_affinity.sql
fa99dde0fe0b3d7da6693d76b2735629dde0dc255ea8c53f0354857a4e80669b 0024_preferred_account_affinity.sql
299d347add09b1e6f086732c0db8269efb640db4ecccb948e17c067b572afc43 0025_request_observation.sql
d4b599066d7382a1c9cb6b3c003200046579e4fce3531f232cbd665a9d92c542 0026_codex_privacy_policy.sql
@@ -0,0 +1,11 @@
-- 隐私规则默认关闭,保留既有请求行为
alter table runtime_settings
add column codex_privacy_policy_json jsonb not null
default '{"enabled":false,"onError":"skip_rule","rules":[]}'::jsonb
check (
jsonb_typeof(codex_privacy_policy_json) = 'object'
and jsonb_typeof(codex_privacy_policy_json->'enabled') = 'boolean'
and codex_privacy_policy_json->>'onError' in ('skip_rule', 'reject_request')
and jsonb_typeof(codex_privacy_policy_json->'rules') = 'array'
and codex_privacy_policy_json ?& array['enabled', 'onError', 'rules']
);
+4 -2
View File
@@ -363,10 +363,10 @@ OpenAI OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不
代理位置优先。`host.timezone` 控制部署日历和管理端时间展示,不代替请求位置设置。
覆盖字段与环境消息识别条件见 [代理位置合同](../docs/api.md#独立代理管理--managed-proxies)
Codex 回答仍显示本机时区时,先核对请求是否使用了预期账号和代理,再区分环境正文与工具结果:
Codex 回答仍显示本机时区时,先核对请求是否使用了预期账号和代理,再区分时间上下文与工具结果:
- 使用新会话询问环境上下文中的时区,避免已有工具输出或历史回答影响判断
- 检查实际环境消息是否满足完整上下文形状;显式普通文本分类或非法 XML 不会被覆盖
- 检查 `environment_context` 和 Desktop 的 `codex_apps_client_time_context` 是否满足[时间上下文识别条件](../docs/api.md#独立代理管理--managed-proxies);显式普通文本分类或非法 XML 不会被覆盖
- 若模型调用 `date` 等工具查询本机,工具结果仍反映真实系统时区,覆盖不会修改客户端操作系统
网关的请求头画像和位置覆盖不提供完整匿名化。正文与工作区 metadata 的保留范围见
@@ -601,6 +601,8 @@ alpha、beta、rc、exp 在 GitHub 标记为 Pre-release,不覆盖 GitHub Late
### 管理端在线更新
官方构建按上述规则在线更新;源码等非官方构建返回不支持原因,不提供可用更新。
最低受支持的在线升级起点为 **v3.18.1**。更旧版本需手动安装 v3.18.1 或新版完整发行包/镜像,
不能依赖旧更新页面直接升级到最新版或选择已不是最新版的过渡版本。
点击「下载并更新」后,操作日志显示下载、校验与安装过程,完成后显示「待重启」及待生效版本,重启后才使用新程序。
「当前版本」始终表示正在运行的版本,「通道最新」表示所选通道的检查结果
+68 -13
View File
@@ -408,9 +408,13 @@ OpenAI 上游错误 envelope 和允许下发的 opaque header 值也不由 canon
`response.metadata` 的业务内容继续交付,仅其中的响应头沿用凭据、账号身份和逐跳头隔离规则。
未改写的 WebSocket 文本不依赖 JSON 旁路解析成功;插件改写后的响应仍复核必要关联,原始 SSE 心跳
和无法提取事实的未改写内容不构成协议失败。
Images 请求不读取或重建 JSON,也不要求或映射模型字段;
它固定使用 OpenAI Provider,
只在原始字节之外完成账号选择、鉴权头替换和端点路由,成功与非容量失败响应正文保持原始字节。
Images 固定使用 OpenAI Provider,不重建正文或映射模型字段;Provider 从原始 JSON 旁路提取图片模型,
按[账号模型限制](#账号模型限制)选号,并复核插件改写后的模型。账号选择、鉴权头替换和端点路由
不改变请求正文,成功与非容量失败响应正文保持原始字节。
Images 排除已知为 Free 套餐的 OAuth 账号;套餐缺失或未知时不推断为 Free,API Key 账号不套用该套餐规则。
其余账号仍需满足模型限制、额度等调度条件,实际生图权限由上游决定
`/v1/alpha/search` 使用相同的 OpenAI Provider 原生端点边界:body(包括 `model`)不解析、不映射,
`x-codex-turn-metadata` 在移除客户端账号身份并按当前 lease 重写 installation ID 后转发;上游账号
Authorization、Cookie、account ID、originator 和 User-Agent 均由代理安全重建。
@@ -717,9 +721,11 @@ Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修
批量接口的调度、分组、模型与代理字段均可省略;省略的字段保持各账号原值。
提供 `groupIds` 时替换完整分组集合,提供 `concurrencyLimit: null` 时恢复继承运行参数
限制适用于 Responses HTTP、WebSocket 及其带压缩触发的请求选号,包括重试、亲和和换号;没有合规账号时沿用
限制适用于 Responses HTTP、WebSocket 及其带压缩触发的请求,以及 Images 生图与编辑选号,包括重试、亲和和换号;没有合规账号时沿用
无可用账号错误,不会回退到被禁止的账号。已开始请求使用冻结的政策,新请求使用已发布的新配置。
Images、独立 Search 及管理员连接测试不受该文本模型限制;连接测试成功只证明指定账号的上游能力。
Images 按请求正文的图片模型 ID 检查,不依赖文本模型目录;可手动添加目录未列出的图片模型 ID。
图片模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。
独立 Search 及管理员连接测试不受该模型限制;连接测试成功只证明指定账号的上游能力。
普通 `/v1/models` 和单模型查询将已发现的 OpenAI 模型关联到来源账号,至少一个来源账号在当前 Key 范围内且政策允许时才展示;
同 Provider 中未发现该模型的 `all` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象
@@ -770,13 +776,19 @@ API 的 `autoLocation` 默认为 `false`;开启时使用已检测位置,测
关联账号的 OpenAI/Codex Responses 请求(HTTP/SSE、WebSocket)优先使用代理位置,否则使用全局
运行设置中已开启的 `requestLocation`;两者均未开启时保留客户端原有位置和时区。全局覆盖按请求冻结,
新请求使用保存后的设置,无需重启;代理覆盖在每次执行时读取,
换号或换出口按该次选定账号解析。位置只影响环境上下文日期/时区和 Web Search 的结构化位置,
换号或换出口按该次选定账号解析。位置只影响客户端时间上下文的日期/时区和 Web Search 的结构化位置,
不改变 epoch 时间戳、真实出口 IP、服务或管理端时区、数据驻留约束及 xAI 请求
环境消息要求 `role: "user"`,文本块为 `type: "input_text"`,完整文本去除首尾空白后由
`<environment_context>` 与 `</environment_context>` 包围且为合法 XML;只替换根节点直属的
`current_date` 和 `timezone`。有 `internal_chat_message_metadata_passthrough.content_item_kinds` 数组时,
仅处理对应分类为 `environments.environment_context` 的文本块;没有分类时,按完整环境上下文识别。
时间上下文支持以下两种消息,文本块均为 `type: "input_text"`:
| 消息角色 | 完整 XML 根标签 | 内容分类 |
| --- | --- | --- |
| `user` | `environment_context` | `environments.environment_context` |
| `developer` | `codex_apps_client_time_context` | `additional_content.codex_apps_client_time_context` |
文本去除首尾空白后须由对应标签完整包围且为合法 XML,只替换根节点直属的 `current_date` 和 `timezone`。
有 `internal_chat_message_metadata_passthrough.content_item_kinds` 数组时,文本块的对应分类须匹配上表;
没有分类时,按消息角色和完整 XML 识别。
显式标为 `user.text` 或其他分类的内容、普通聊天中引用的示例、工具结果及无法解析的上下文保持原样。
客户端实际本机时区不受影响,工具读取本机时区后的输出仍可包含真实值;排查见
[时区与客户端环境信息](../deploy/README.md#时区与客户端环境信息)
@@ -887,7 +899,8 @@ API Key 账号使用以下独立凭据形态:
`base_url` 是 API 前缀,追加 `responses`、`models`、`images/generations`、`images/edits` 或 `alpha/search`,
不会自动补 `/v1`;支持根路径和自定义前缀。
地址仅允许 HTTPS,HTTP 只允许本机回环;拒绝 URL 中的认证信息、查询串和 fragment。
地址支持 HTTP 和 HTTPS,包括内网 IP 与容器名,拒绝 URL 中的认证信息、查询串和 fragment。
HTTP 会明文传输 API Key 和请求内容,仅用于可信网络。
`transport` 可省略(默认 `http`)或设为 `prefer_websocket`。API Key 使用 Bearer 认证与普通 JSON,
不携带 OAuth Cookie 或 ChatGPT 身份。上游模型列表使用标准 `/models` 格式,按账号和凭据版本隔离;
目录用于模型发现,不作为能力白名单,未列出的模型仍交由上游判断。
@@ -1309,6 +1322,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
| --- | --- | --- |
| `GET` | `/api/admin/settings` | 读取运行设置 |
| `POST` | `/api/admin/settings/update` | 原子保存运行设置;身份配置按显式提供的 Provider 项更新 |
| `POST` | `/api/admin/settings/privacy/preview` | 使用管理员提供的样本预览隐私规则,不保存设置或样本 |
| `GET` | `/api/admin/settings/client-downloads/codex-desktop/windows` | 提取 Codex Desktop Windows 离线安装直链;`refresh=true` 强制刷新进程内短缓存 |
| `GET` | `/api/admin/settings/client-profiles/{provider}` | 读取 `openai` 或 `xai` 的可选配置和 `globalConfiguration` |
| `POST` | `/api/admin/settings/client-profiles/{provider}/preview` | `{ configuration }`,完整配置对象或 `null`(解析当前通用设置);只预览,不保存 |
@@ -1327,6 +1341,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
| 并发控制版本 | `configRevision` |
| [客户端身份](#provider-客户端身份) | `providerRequestProfiles`、`openaiClientProfile`、`xaiClientProfile` |
| [请求位置](#请求位置) | `requestLocationEnabled`、`requestLocation` |
| [隐私策略](#隐私策略) | `codexPrivacyPolicy` |
| 模型映射 | `modelMappings` |
| 凭据刷新 | `refreshMarginSeconds`、`refreshConcurrency` |
| [并发与排队](#并发与排队) | `maxConcurrentPerAccount`、`maxWaitingPerKey`、`maxWaitingPerAccount`、`openaiGuardianReservedConcurrency`、`concurrencyWaitTimeoutSeconds`、`requestIntervalMs` |
@@ -1356,6 +1371,47 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
关联代理配置了自定义位置时优先使用代理值。保存后通过现有配置发布机制对新请求生效,
已开始请求及其重试保持同一份全局值;普通文本、绝对时间戳和数据驻留要求不受影响
### 隐私策略
`codexPrivacyPolicy` 为必填对象,默认 `{ enabled: false, onError: "skip_rule", rules: [] }`。
只处理经网关发送的 OpenAI Responses HTTP/WebSocket 和独立 JSON 端点请求,不控制客户端直接发送的遥测。
规则按列表顺序执行,关闭策略或单条规则会保留配置。没有字段保护名单,认证、会话、工具等字段也可配置;改写后对上游行为的影响由配置者承担
每条规则包含唯一 `id`、`name`、`enabled`、`scope`、`selector`、`action`、`pattern`、`replacement`、
`value`、`replaceAll`、`caseInsensitive`、`multiLine`。作用范围如下:
| `scope` | 选择对象 |
| --- | --- |
| `turn_metadata` | 请求头、正文及 `client_metadata` 中的 turn metadata 对象,各副本独立处理 |
| `desktop_git_context` | 自动附加的 Desktop Git 上下文中解码后的 JSON 对象 |
| `environment_text` | 已识别的环境上下文或 Desktop 时间上下文文本,`selector` 为 `$` |
| `request_body` | 最终出站 JSON 正文 |
| `request_header` | 最终业务请求头,`selector` 直接填写头名称,重复头逐个处理 |
JSON 路径支持 `$`、`.成员`、`["带特殊字符的键"]`、`[索引]`、`.*` 和 `[*]`,不支持递归和过滤表达式。
缺少字段视为未命中;类型不符属于执行失败。`regex_replace` 替换字符串,`set_value` 保持原字段类型设置 JSON 值,
`rename_key` 替换所选对象的直接键名,重名时失败;`remove_field` 删除所选字段或数组项,不能删除作用范围根节点。
删除动作的 `pattern: null` 表示无条件删除,字符串模式表示只删除字符串值匹配的字段或数组项。
请求头规则生成的值必须是 ASCII 文本,以保持 HTTP 与 WebSocket opening 的一致性;metadata 范围会自动转义 Unicode
例如,`turn_metadata` 范围的 `$.workspaces` 配合 `remove_field` 删除整个工作区映射;
`$.workspaces.*.associated_remote_urls.*` 配合 `remove_field` 和 `pattern: "private-org"` 只删除命中的远端;
`$.workspaces` 配合 `rename_key` 可改写工作区路径键名。环境文本替换必须保留 XML 包裹结构
正则使用 Rust `regex` 语法,不支持前后顾或模式反向引用;替换内容支持 `${1}`、`${name}` 和字面量 `$$`。
`replaceAll` 控制首个或全部匹配,另支持忽略大小写和多行锚点。最多 32 条规则,路径最多 16 层、1024 字节,
模式与替换内容分别最多 4096 字节,固定值编码后最多 4096 字节。XML 上下文最多 10000 个节点,环境文本最多 64 层。
执行限制为正文 32 MiB、单目标文本及编码后的上下文 1 MiB、
累计处理文本 16 MiB、10000 次访问与匹配步骤、100 ms 检查预算;时间预算在处理检查点检查,不是硬实时中断
执行失败时,`skip_rule` 撤销本条规则在所有副本上的修改并继续,意味着本条脱敏未完成;
`reject_request` 阻止发送并返回本地请求错误,不因该错误自动重试或换号。设置保存时校验所有规则,包含未启用的规则
预览请求为 `{ policy, body, headers, turnMetadata }`,其中 `headers` 为头名称到字符串数组的映射,
`turnMetadata` 为编码后的 JSON 字符串或 `null`。正文最多 256 KiB,请求头与独立 metadata 合计最多 256 KiB。
响应返回处理后的三个承载值,以及 `outcomes: [{ ruleId, matches, status, reason }]`;
状态为 `disabled`、`unmatched`、`applied` 或 `skipped`,错误不回显请求内容或表达式
### 并发与排队
`maxConcurrentPerAccount` 是默认账号并发上限,取值 0~4294967295;`0` 表示不限制。
@@ -1933,8 +1989,7 @@ Provider metadata 分别保留 `requestedServiceTier` 与 `upstreamServiceTier`
下载并解包后校验发行身份,插件版本不匹配不阻止更新或回滚,兼容性风险在重启前提示。
两条路径在文件交换前后复核全局配置版本,文件替换失败或取消时成组恢复二进制、Web 资源和官方插件目录
详情响应的 `restartConfirmationSupported=true` 表示运行进程支持重启前确认。
`restart/check` 返回 `targetVersion`、`releaseManifestSha256`、`configRevision` 和 `incompatiblePlugins`,
客户端重启前调用 `restart/check`,返回 `targetVersion`、`releaseManifestSha256`、`configRevision` 和 `incompatiblePlugins`,
每项包含 `instanceId`、`name`、`reason`,检查不修改插件状态。源码运行的目标版本与发行摘要为空,检查当前宿主兼容性。
存在不兼容插件时,客户端展示列表并取得确认后,将完整检查结果作为 `confirmation` 提交重启请求。
服务端在重启锁内重新检查,目标或配置变化返回 `40901`,须重新检查并确认。确认后保留启用配置,重启时逐个尝试加载插件。
+12 -2
View File
@@ -293,6 +293,12 @@ service;客户端原生对象保存在有界进程缓存中,与套餐 Redis
`RuntimeSnapshot → RoutingPlan → AttemptContext` 冻结传递,关闭时保留客户端原有字段;
Provider 在选定账号后应用代理位置覆盖。请求期间不额外查询全局设置,配置发布不改变已开始请求的全局值
隐私规则合同及编译端口由 Core 定义,OpenAI Provider 拥有路径、正则和承载结构的解释,Admin 的保存校验与样本预览复用同一实现。
Store 将策略与设置 revision 原子持久化;编译结果随快照、计划及 attempt 冻结,未改变策略的请求设置覆盖复用已编译对象。
规则在宿主请求头和 WS metadata 投影完成后作用于本次出站副本,重试及传输回退从原请求重新构造,避免重复替换。
每条规则跨承载副本原子提交;发送前拒绝携带 `NotSent` 和禁止重试标记。被规则改写的握手头参与 WS 池身份,防止复用旧值,
关闭策略不进入改写管线。字段与执行限制见[隐私策略 API](api.md#隐私策略)
Fast 限制由同一快照链路冻结:Client Key 所有绑定分组的开关取逻辑或;
禁用分组仍贡献限制,无分组 Key 不限制 Fast,不按所选账号的分组重新解释。
OpenAI Provider 在独立编码请求上、生成上游头与观测前统一应用顶层档位覆盖,HTTP、WS 与重试共用。
@@ -384,8 +390,9 @@ Client Key 鉴权完成后,API adapter 从有界请求头识别客户端,Cor
Responses 按模型目录编译候选;全局模型映射是精确映射,未命中时模型名原样交给候选 Provider。
Images 与 standalone Search 是 OpenAI Provider 自有端点:两者都不参与文本模型映射,只在 Client Key
的账号范围确实包含 OpenAI 账号时生成单一 OpenAI 候选。Images 不要求模型字段;Search body 中的模型
及其他字段保持原始 bytes 并由上游解释
的账号范围确实包含 OpenAI 账号时生成单一 OpenAI 候选。Images 的图片模型由 Provider 从正文旁路
提取,用于账号模型政策检查,并在中间件改写后复验当前租约账号;不要求图片模型进入文本目录。
Search body 中的模型及其他字段保持原始 bytes 并由上游解释
## 5. Provider 与协议边界
@@ -487,6 +494,9 @@ client,OIDC 的 JWKS 缓存与单飞归属对应出口状态。自动刷新提
客户端据此开放原生生图工具,图片生成和编辑由 Images 路由处理。
这只解决客户端能力识别;模型能力、客户端限制和上游账号的实际生图权限仍分别检查
OpenAI Provider 将 OAuth 套餐资格作为 Images 选号条件,复用账号持久事实,
在获取租约前与冻结的模型黑白名单共同过滤候选;亲和与重试沿用同一资格检查
`X-OpenAI-Actor-Authorization` 是客户端能力标记,不是账号凭据。API 解码和 OpenAI Provider
都过滤该 header,网关继续校验 Client Key,上游认证由服务端账号产生。
客户端配置示例维护在 [部署文档](../deploy/README.md#客户端配置)
@@ -1,9 +1,11 @@
import type { RequestOptions } from '../../request'
import type { CodexPrivacyPolicy } from './privacy'
import type { ClientProfileSelection, ProviderRequestProfiles, ProviderRequestProfileUpdates, XaiClientProfileSelection } from './profiles'
import request from '../../request'
export * from './backups'
export * from './pricing'
export * from './privacy'
export * from './system'
export interface RequestLocation {
@@ -35,6 +37,7 @@ export interface RuntimeSettings {
openaiClientProfile: ClientProfileSelection | null
xaiClientProfile: XaiClientProfileSelection | null
codexPrivacyPolicy: CodexPrivacyPolicy
requestLocationEnabled: boolean
requestLocation: RequestLocation
modelMappings: Record<string, string>
@@ -0,0 +1,46 @@
import type { RequestOptions } from '../../request'
import request from '../../request'
export type PrivacyScope = 'turn_metadata' | 'desktop_git_context' | 'environment_text' | 'request_body' | 'request_header'
export type PrivacyAction = 'regex_replace' | 'set_value' | 'rename_key' | 'remove_field'
export type JsonValue = unknown
export interface PrivacyRule {
id: string
name: string
enabled: boolean
scope: PrivacyScope
selector: string
action: PrivacyAction
pattern: string | null
replacement: string
value: JsonValue
replaceAll: boolean
caseInsensitive: boolean
multiLine: boolean
}
export interface CodexPrivacyPolicy {
enabled: boolean
onError: 'skip_rule' | 'reject_request'
rules: PrivacyRule[]
}
export interface PrivacySample {
body: JsonValue
headers: Record<string, string[]>
turnMetadata: string | null
}
export interface PrivacyPreviewResult extends PrivacySample {
outcomes: { ruleId: string, matches: number, status: string, reason: string | null }[]
}
export function previewPrivacyPolicy(data: PrivacySample & { policy: CodexPrivacyPolicy }, options: RequestOptions = {}) {
return request<PrivacyPreviewResult>({
url: '/api/admin/settings/privacy/preview',
method: 'POST',
data,
...options,
})
}
@@ -34,7 +34,6 @@ export interface SystemUpdateDetail {
notes: string | null
cached: boolean
updateSupported: boolean
restartConfirmationSupported?: boolean
unsupportedReason: string | null
warning: string | null
}
@@ -196,11 +196,6 @@ async function handleRestart() {
try {
if (!updateInfo.value)
throw new Error('请等待系统更新信息加载完成')
// 文件已更新但旧进程仍在运行时,旧 API 继续使用安装前的兼容预检。
if (!updateInfo.value.restartConfirmationSupported) {
await restartNow()
return
}
restartPlan.value = await checkSystemRestart()
if (restartPlan.value.incompatiblePlugins.length) {
restartConfirmOpen.value = true
+1 -1
View File
@@ -452,7 +452,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
setPhase({ kind: 'failed' })
}
async function restartNow(confirmation?: SystemRestartPlan) {
async function restartNow(confirmation: SystemRestartPlan) {
if (restarting.value)
return
@@ -30,9 +30,8 @@ export function apiKeyAccountError(form: ApiKeyAccountForm, editing = false): st
return '上游 API 地址不能超过 2048 个字符'
try {
const url = new URL(form.base_url)
const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(url.hostname)
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || url.username || url.password || url.search || url.hash)
return '请输入不含认证、查询参数或片段的 HTTPS 地址'
if (!['https:', 'http:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash)
return '请输入不含认证、查询参数或片段的 HTTP 或 HTTPS 地址'
}
catch {
return '请输入完整的上游 API 地址'
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountAffinity } from '@/api'
import { BaseCard, BaseForm, BaseFormItem, BaseInput, BasePopover, BaseSegmented } from '@codex-proxy/ui'
import { BaseCard, BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover, BaseSegmented } from '@codex-proxy/ui'
import { CircleAlert } from '@lucide/vue'
const props = defineProps<{ disabled: boolean }>()
@@ -17,9 +17,9 @@ const affinityOptions = [{ label: '宽松', value: 'relaxed' }, { label: '优先
<template #label-extra>
<BasePopover class="-my-1" trigger="hover-click" placement="top-start">
<template #trigger="{ open }">
<button type="button" aria-label="账号亲和说明" :aria-expanded="open" class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none">
<BaseIconButton label="账号亲和说明" :title="undefined" :aria-expanded="open" class="size-6! hover:bg-transparent! active:bg-transparent!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</button>
</BaseIconButton>
</template>
<div class="max-w-72 space-y-2 px-3 py-2 text-cp-sm leading-relaxed text-cp-text-secondary">
<p>宽松:会话内请求直接按调度策略选号,不优先主账号</p>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseCheckbox, BaseForm, BaseFormItem, BaseInput, BasePopover, BaseSwitch } from '@codex-proxy/ui'
import { BaseCard, BaseCheckbox, BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover, BaseSwitch } from '@codex-proxy/ui'
import { Activity, CircleAlert, Gauge, Snowflake, Timer } from '@lucide/vue'
import { useId } from 'vue'
@@ -125,15 +125,15 @@ const adaptiveConcurrencyHintId = useId()
/>
<BasePopover class="-my-1" trigger="hover-click" placement="top-start" :hover-delay="240">
<template #trigger="{ open }">
<button
type="button"
class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none"
aria-label="自适应并发下调说明"
<BaseIconButton
label="自适应并发下调说明"
:title="undefined"
class="size-6! hover:bg-transparent! active:bg-transparent!"
:aria-expanded="open"
:aria-describedby="open ? adaptiveConcurrencyHintId : undefined"
>
<CircleAlert class="size-3.5" aria-hidden="true" />
</button>
</BaseIconButton>
</template>
<p :id="adaptiveConcurrencyHintId" role="tooltip" class="m-0 max-w-72 px-3 py-2 text-cp-sm leading-relaxed text-cp-text-secondary">
修改账号并发上限,恢复后不自动调高
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BaseCard, BaseForm, BaseFormItem, BaseInput, BasePopover } from '@codex-proxy/ui'
import { BaseCard, BaseForm, BaseFormItem, BaseIconButton, BaseInput, BasePopover } from '@codex-proxy/ui'
import { CircleAlert, Gauge, Timer } from '@lucide/vue'
@@ -62,9 +62,9 @@ const openaiGuardianReservedConcurrency = defineModel<string>('openaiGuardianRes
<template #label-extra>
<BasePopover class="-my-1" trigger="hover-click" placement="top-start">
<template #trigger="{ open }">
<button type="button" aria-label="自动审批独立并发说明" :aria-expanded="open" class="inline-flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none transition-colors hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline motion-reduce:transition-none">
<BaseIconButton label="自动审批独立并发说明" :title="undefined" :aria-expanded="open" class="size-6! hover:bg-transparent! active:bg-transparent!">
<CircleAlert class="size-3.5" aria-hidden="true" />
</button>
</BaseIconButton>
</template>
<div class="max-w-72 space-y-2 px-3 py-2 text-cp-sm leading-relaxed text-cp-text-secondary">
<p>仅对 OpenAI 账号生效,普通并发为 10、审批名额为 3 时,两类请求分别最多运行 10 个和 3 个,互不占用名额</p>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { SmartSchedulingConfig } from '@/api'
import { BaseButton, BaseFormItem, BaseInput, BaseModal, BasePopover, BaseSwitch } from '@codex-proxy/ui'
import { BaseButton, BaseFormItem, BaseIconButton, BaseInput, BaseModal, BasePopover, BaseSwitch } from '@codex-proxy/ui'
import { CircleAlert, CircleHelp } from '@lucide/vue'
import { computed, reactive, shallowRef, useId, watch } from 'vue'
@@ -75,9 +75,9 @@ function confirm() {
{{ field.label }}
<BasePopover trigger="hover-click" placement="top-start">
<template #trigger="{ open: helpOpen }">
<button type="button" class="inline-flex size-6 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline" :aria-label="`${field.label}说明`" :aria-expanded="helpOpen">
<BaseIconButton :label="`${field.label}说明`" :title="undefined" :aria-expanded="helpOpen" class="size-6! hover:bg-transparent! active:bg-transparent!">
<CircleHelp class="size-3.5" />
</button>
</BaseIconButton>
</template>
<p class="m-0 max-w-64 px-3 py-2 text-cp-sm leading-relaxed">{{ field.help }}</p>
</BasePopover>
@@ -96,9 +96,9 @@ function confirm() {
回切行为
<BasePopover trigger="hover-click" placement="top-start">
<template #trigger="{ open: helpOpen }">
<button type="button" class="inline-flex size-6 cursor-pointer items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp-text-tertiary outline-none hover:text-cp-text focus-visible:ring-2 focus-visible:ring-cp-control-outline" aria-label="回切行为说明" :aria-expanded="helpOpen">
<BaseIconButton label="回切行为说明" :title="undefined" :aria-expanded="helpOpen" class="size-6! hover:bg-transparent! active:bg-transparent!">
<CircleAlert class="size-3.5" />
</button>
</BaseIconButton>
</template>
<p class="m-0 max-w-72 px-3 py-2 text-cp-sm leading-relaxed">
高权重账号恢复可用后,后续允许重新选号的请求优先回切<br>原生续写仍绑定原账号,同权重账号保留已有会话亲和

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