Compare commits

...
16 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
ZyyandCodex 705f0fad06 chore(release): v3.22.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 18:11:24 +08:00
ZyyandCodex f0c60f501b fix(client): enable remote compaction in Codex config
Declare the V2 remote compaction capability in generated Codex configuration and deployment examples. Document that CC Switch imports do not preserve the full capability configuration.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 18:05:07 +08:00
ZyyandCodex d1febdd249 fix(openai): remove precommit byte limit
Keep large structural events buffered until semantic output, terminal completion, EOF, or the replay grace deadline. Retain byte counts for diagnostics and preserve historical byte_limit exports.

Refs #369

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 18:05:01 +08:00
ZyyandCodex 53d4086cc0 fix(openai): preserve opaque request fields and websocket messages
Forward numeric and extended reasoning effort values and preserve standalone endpoint business headers.

Retain business metadata and original WebSocket text through provider, middleware, and client delivery. Validate rewritten plugin output without rejecting unchanged heartbeats or opaque messages.

Add HTTP, WebSocket, and plugin process regressions for the passthrough boundaries.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 17:23:40 +08:00
ZyyandCodex ce03e8119d fix(diagnostics): retain health and xAI worker failure causes
Record health component failures through operational diagnostics with transition deduplication. Preserve quota and catalog failure context without interrupting the account batch.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 17:10:29 +08:00
ZyyandCodex 3f05b6450c fix(usage): preserve precommit facts in diagnostics export
Export allowlisted release reasons, prefetch bytes and wait times, and document diagnostics schema v4.

Refs #369

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 16:55:12 +08:00
ZyyandCodex a4b73099b5 fix(openai): pass through websocket control frames
Forward non-create text frames unchanged over the original upstream WebSocket while preserving response.create admission and serialization. Keep the control transport available after completion and relay idle upstream events without creating another inference attempt.

Cover unknown frames, late interrupts, terminal settlement, binding revocation, and connection reuse.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 16:26:31 +08:00
ZyyandCodex d875a62652 chore(release): v3.22.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-09 14:10:51 +08:00
ZyyandCodex 98704dfde8 fix(usage): parse response critical path timings
Read response-scoped engine and API overhead metrics while excluding logical-turn totals and incomplete observations. Validate nested response IDs and cover the observed payload over SSE and WebSocket.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 13:51:40 +08:00
ZyyandCodex b07bca312a fix(upgrade): handle long migrations and unify forced shutdown
Extend the startup healthcheck grace period to five minutes and report elapsed migration time every fifteen seconds without changing published migrations.

Use one shutdown path for OS signals, internal cancellation, and managed restarts. Stop listening without waiting for active sessions, preserve bounded worker cleanup, and remove the obsolete connection drain timeout.

Cover shutdown entry points and worker cleanup with real HTTP lifecycle tests and document the deployment behavior.

Co-authored-by: Codex <noreply@openai.com>
2026-10-09 13:51:14 +08:00
173 changed files with 7084 additions and 1460 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",
+1 -1
View File
@@ -1,4 +1,4 @@
//! 服务模式完成全部组装后交给 Host 监听,并在请求和 Worker 排空后关闭插件
//! 服务模式完成全部组装后交给 Host 监听,并在停止监听与 Worker 收尾后关闭插件
use gateway_host::ConfigError;
@@ -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,
}
+89 -15
View File
@@ -1,10 +1,12 @@
//! `/healthz` 对 Core、Store 与 Host 关键 worker 健康事实的聚合
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use axum::{extract::State, http::StatusCode};
use futures::future::join_all;
use gateway_core::diagnostics::{OperationalDiagnostics, OperationalFailure};
use gateway_core::error::{ErrorDetails, ErrorSource};
use gateway_core::health::{
HealthProbe, HealthState, WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource,
WorkerRuntimeState,
@@ -19,6 +21,16 @@ const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
pub(crate) struct HealthStatus {
probes: Arc<[Arc<dyn HealthProbe>]>,
workers: Arc<dyn WorkerHealthSource>,
diagnostics: Arc<dyn OperationalDiagnostics>,
failures: Arc<Mutex<Vec<HealthFailure>>>,
}
#[derive(Clone, PartialEq, Eq, Debug, thiserror::Error)]
#[error("{component} is {state}: {reason}")]
struct HealthFailure {
component: String,
state: &'static str,
reason: String,
}
impl HealthStatus {
@@ -26,31 +38,93 @@ impl HealthStatus {
pub(crate) fn new(
probes: Vec<Arc<dyn HealthProbe>>,
workers: Arc<dyn WorkerHealthSource>,
diagnostics: Arc<dyn OperationalDiagnostics>,
) -> Self {
Self {
probes: probes.into(),
workers,
diagnostics,
failures: Arc::default(),
}
}
pub(crate) async fn healthy(&self) -> bool {
if !self
let mut failures: Vec<_> = self
.workers
.snapshot()
.iter()
.filter(|worker| worker_affects_healthz(worker))
.all(worker_is_healthy)
{
return false;
}
let checks = self.probes.iter().map(|probe| probe.check());
tokio::time::timeout(HEALTH_CHECK_TIMEOUT, join_all(checks))
.await
.is_ok_and(|states| {
states
.into_iter()
.all(|state| matches!(state, HealthState::Healthy))
.into_iter()
.filter(|worker| worker_affects_healthz(worker) && !worker_is_healthy(worker))
.map(|worker| HealthFailure {
component: match worker.key {
WorkerHealthKey::Task(id) => format!("worker:{id}"),
WorkerHealthKey::Disabled(kind) => format!("worker:{kind:?}"),
},
state: "unhealthy",
reason: format!(
"state={:?}; last_error={}",
worker.state,
worker.last_error.as_deref().unwrap_or("none")
),
})
.collect();
// 逐个探针共享截止时间,保留已完成结果并指出具体超时组件
let deadline = tokio::time::Instant::now() + HEALTH_CHECK_TIMEOUT;
let checks = self.probes.iter().map(|probe| async move {
let (state, reason) = match tokio::time::timeout_at(deadline, probe.check()).await {
Ok(HealthState::Healthy) => return None,
Ok(HealthState::Degraded(reason)) => ("degraded", reason),
Ok(HealthState::Unhealthy(reason)) => ("unhealthy", reason),
Err(_) => ("timed_out", "health probe exceeded 2s deadline".to_owned()),
};
Some(HealthFailure {
component: format!("probe:{}", probe.name()),
state,
reason,
})
});
failures.extend(join_all(checks).await.into_iter().flatten());
let healthy = failures.is_empty();
let changed = {
let mut previous = self
.failures
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let changed = failures
.iter()
.filter(|failure| !previous.contains(failure))
.cloned()
.collect::<Vec<_>>();
for recovered in previous
.iter()
.filter(|old| !failures.iter().any(|new| new.component == old.component))
{
tracing::info!(component = %recovered.component, "health component recovered");
}
*previous = failures;
changed
};
// 只记录故障变化;使用既有有界观测队列,文件日志失效时仍有独立的运维证据
for failure in changed {
tracing::warn!(component = %failure.component, state = failure.state, "health check failed");
let mut event = OperationalFailure::new(
"health",
"check",
"health_check_failed",
"Health check failed",
);
event.correlation_id = Some(failure.component.clone());
event.details =
ErrorDetails::capture(Some(&ErrorSource::new(failure.clone())), None, false);
if self.diagnostics.record_failure(event).await.is_err() {
tracing::warn!("health diagnostic could not be recorded");
// 入队失败不能抑制下次探测的记录机会,也不改变本次健康判定
self.failures
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|previous| previous != &failure);
}
}
healthy
}
}
+1 -1
View File
@@ -190,7 +190,7 @@ pub fn initialize(
let state = ApiState {
admin,
openai: OpenAiService::new(execution, lifecycle),
health: HealthStatus::new(probes, worker_health),
health: HealthStatus::new(probes, worker_health, diagnostics.clone()),
};
let index = config.asset_directory.join("index.html");
let mut router = Router::new()
@@ -62,7 +62,7 @@ where
let started = match service
.start_prepared_provider_endpoint(
prepared, operation,
// 图像/搜索等原生端点没有对应的上游模型权限。
// 原生端点的模型语义由 Provider 从正文提取并检查账号政策
None, client_ip, user_agent, endpoint,
)
.await
@@ -95,15 +95,25 @@ impl OpenAiRequestHeaders {
}
}
/// 独立端点只消费会话身份与 turn metadata,不携带 Responses 的连接状态
/// 独立端点保留业务扩展头与会话身份,不携带 Responses 的连接状态
pub(crate) fn session_context(&self) -> Map<String, Value> {
let mut context = Map::new();
insert_protocol_context(&mut context, "session_id", self.session_id.as_ref());
insert_protocol_context(&mut context, "thread_id", self.thread_id.as_ref());
insert_protocol_context(&mut context, "turn_metadata", self.turn_metadata.as_ref());
self.insert_passthrough_headers(&mut context);
context
}
fn insert_passthrough_headers(&self, context: &mut Map<String, Value>) {
if !self.passthrough_headers.is_empty() {
context.insert(
PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(),
Value::Array(self.passthrough_headers.clone()),
);
}
}
fn protocol_context(
&self,
use_websocket: Option<bool>,
@@ -153,12 +163,7 @@ impl OpenAiRequestHeaders {
insert_protocol_context(&mut context, "turn_id", self.turn_id.as_ref());
insert_protocol_context(&mut context, "responses_lite", self.responses_lite.as_ref());
insert_protocol_context(&mut context, "memgen_request", self.memgen_request.as_ref());
if !self.passthrough_headers.is_empty() {
context.insert(
PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(),
Value::Array(self.passthrough_headers.clone()),
);
}
self.insert_passthrough_headers(&mut context);
if let Some(use_websocket) = use_websocket {
context.insert("use_websocket".to_owned(), Value::Bool(use_websocket));
}
@@ -47,7 +47,7 @@ impl OpenAiResponsesEncoder {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let projected = client_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// 当前 Codex 不消费 Responses `error` event,会在 EOF 时丢失失败原因
// 在客户端 SSE 边界统一投影成它能识别的 `response.failed`
@@ -70,22 +70,29 @@ impl OpenAiResponsesEncoder {
{
return vec![raw_sse_frame.clone()];
}
let data = if projected.is_none() {
wire.raw_websocket_message()
.map(str::to_owned)
.unwrap_or_else(|| data.to_string())
} else {
data.to_string()
};
vec![Bytes::from(encode_sse_event_with_metadata(
effective_event_type(wire).unwrap_or_default(),
&data.to_string(),
&data,
wire.sse_id(),
wire.sse_retry(),
))]
}
/// 消费一个 Provider event,并返回 WebSocket JSON messages
/// 消费一个 Provider event,并返回完整 WebSocket 文本消息
pub fn push_websocket(&mut self, event: &ProviderEvent) -> Vec<String> {
self.observe_canonical_identity(event);
let Some(wire) = openai_wire(event).filter(|wire| wire.has_json_data()) else {
let Some(wire) = openai_wire(event) else {
return Vec::new();
};
self.observe_wire(wire);
let projected = client_failure_payload(wire);
let projected = client_payload(wire);
let data = projected.as_ref().unwrap_or_else(|| wire.data());
// WS 客户端只对它无法消费的裸 `error` 帧做投影:codex 的 WS 端点
// 会静默忽略缺少 status 且不含内置可重试错误码的 `error` 帧,客户
@@ -104,6 +111,14 @@ impl OpenAiResponsesEncoder {
{
return vec![data.to_string()];
}
if projected.is_none()
&& let Some(raw) = wire.raw_websocket_message()
{
return vec![raw.to_owned()];
}
if !wire.has_json_data() {
return Vec::new();
}
vec![data.to_string()]
}
@@ -248,15 +263,43 @@ fn effective_event_type(wire: &ProtocolWireEvent) -> Option<&str> {
.or_else(|| wire.data().get("type").and_then(Value::as_str))
}
fn client_failure_payload(wire: &ProtocolWireEvent) -> Option<Value> {
fn client_payload(wire: &ProtocolWireEvent) -> Option<Value> {
if matches!(
effective_event_type(wire),
Some("error" | "response.failed")
) {
capacity_error_for_client(wire.data())
} else {
None
return capacity_error_for_client(wire.data());
}
if !matches!(
effective_event_type(wire),
Some("response.metadata" | "codex.response.metadata")
) {
return None;
}
let headers = wire.data().get("headers")?.as_object()?;
let connection_options: Vec<String> = headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("connection"))
.flat_map(|(_, value)| match value {
Value::Array(values) => values.iter().filter_map(Value::as_str).collect::<Vec<_>>(),
_ => value.as_str().into_iter().collect(),
})
.flat_map(|value| value.split(','))
.map(|name| name.trim().to_ascii_lowercase())
.collect();
if headers
.keys()
.all(|name| super::response_header_is_forwardable(name, &connection_options))
{
return None;
}
// 只剥离既有响应头隔离策略禁止的字段,metadata 业务扩展保持不透明
let mut projected = wire.data().clone();
projected
.get_mut("headers")?
.as_object_mut()?
.retain(|name, _| super::response_header_is_forwardable(name, &connection_options));
Some(projected)
}
/// 判断 WS 上游的 `error` 帧是否已经是客户端可直接消费的形状
@@ -209,6 +209,11 @@ impl ResponsesDeliveryValidator {
transformed: bool,
facts: &ResponseValidationFacts,
) -> Result<(), ResponseValidationError> {
// 未改写的未知载荷不参与本地关联判断,不能因旁路解析失败截断原流
// 插件实际改写的载荷仍须满足交付合同
if parsed.is_err() && !transformed {
return Ok(());
}
if !self.active && !transformed {
if parsed.and_then(|frame| self.state.apply(frame)).is_err() {
self.invalid_prefix = true;
@@ -253,7 +258,7 @@ impl ResponsesDeliveryValidator {
return Err(ResponseValidationError);
}
}
ParsedFrame::Event { .. } | ParsedFrame::Done => {}
ParsedFrame::Event { .. } | ParsedFrame::Done | ParsedFrame::NonEvent => {}
}
self.state.apply(parsed)
}
@@ -292,6 +297,7 @@ impl ResponsesDeliveryValidator {
enum ParsedFrame {
Event { event_type: String, value: Value },
Done,
NonEvent,
}
impl ParsedFrame {
@@ -301,6 +307,10 @@ impl ParsedFrame {
return Ok(Self::Done);
}
let mut events = parse_sse_events(frame).map_err(|_| ResponseValidationError)?;
// 注释和仅含 SSE 元数据的帧不承载响应业务事实
if events.is_empty() {
return Ok(Self::NonEvent);
}
if events.len() != 1 {
return Err(ResponseValidationError);
}
@@ -363,6 +373,9 @@ struct DeliveryState {
impl DeliveryState {
fn apply(&mut self, frame: ParsedFrame) -> Result<(), ResponseValidationError> {
if matches!(frame, ParsedFrame::NonEvent) {
return Ok(());
}
if self.done {
return Err(ResponseValidationError);
}
@@ -12,7 +12,7 @@ use gateway_core::engine::middleware::{
MiddlewareBody, MiddlewareError, MiddlewareFrame, MiddlewareFraming, MiddlewareHeader,
MiddlewareResponse,
};
use gateway_core::engine::response_control::ResponseControl;
use gateway_core::engine::response_control::{ResponseControl, ResponseControlUnavailable};
use gateway_core::error::{GatewayError, GatewayErrorKind};
use gateway_core::event::ProviderResponseHeader;
use gateway_core::operation::ProviderSessionState;
@@ -25,7 +25,7 @@ use super::{
super::{DecodedResponsesRequest, OpenAiResponsesEncoder, ProtocolErrorBody},
connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext},
protocol::{
decode_response_interrupt, error_event, initial_engine_error_event, response_metadata_event,
error_event, initial_engine_error_event, is_response_create, response_metadata_event,
},
};
@@ -137,7 +137,7 @@ pub(super) async fn forward_response(
replay: &mut ConnectionReplaySnapshot,
capture: ReplayCaptureHandle,
validation: ResponseValidationFacts,
response_control: ResponseControl,
response_control: &ResponseControl,
) -> ForwardOutcome {
let (protocol, status, headers, mut body, _) = response.into_parts();
if protocol != "openai" || StatusCode::from_u16(status).is_err() {
@@ -150,8 +150,14 @@ pub(super) async fn forward_response(
ProviderResponseHeader::new(name, value)
})
.collect::<Vec<_>>();
let first = match next_body_input(connection, body.as_mut(), &response_control, &request_id)
.await
let first = match next_body_input(
connection,
body.as_mut(),
response_control,
&request_id,
false,
)
.await
{
BodyInput::Frame(Ok(Some(frame))) => frame,
BodyInput::Frame(Ok(None)) => {
@@ -197,8 +203,14 @@ pub(super) async fn forward_response(
let frame = match current.take() {
Some(frame) => frame,
None => {
match next_body_input(connection, body.as_mut(), &response_control, &request_id)
.await
match next_body_input(
connection,
body.as_mut(),
response_control,
&request_id,
terminal_seen,
)
.await
{
BodyInput::Frame(Ok(Some(frame))) => frame,
BodyInput::Frame(Ok(None)) if terminal_seen && body.is_finalized() => {
@@ -284,8 +296,10 @@ fn detach_body(body: Box<dyn MiddlewareBody>) {
}
fn validate_frame(frame: &MiddlewareFrame) -> Result<(), MiddlewareError> {
if frame.framing() != MiddlewareFraming::JsonDocument
|| serde_json::from_slice::<serde::de::IgnoredAny>(frame.bytes()).is_err()
if !matches!(
frame.framing(),
MiddlewareFraming::JsonDocument | MiddlewareFraming::RawBytes
) || std::str::from_utf8(frame.bytes()).is_err()
{
return Err(MiddlewareError::InvalidState);
}
@@ -302,6 +316,7 @@ async fn next_body_input(
body: &mut dyn MiddlewareBody,
response_control: &ResponseControl,
request_id: &Arc<str>,
terminal_seen: bool,
) -> BodyInput {
// 处理控制帧时继续持有同一个读取 future,不能取消正在加工正文的中间件
let frame = body.next_frame();
@@ -312,19 +327,11 @@ async fn next_body_input(
let Some(event) = event else { return BodyInput::Disconnect; };
match &event.event {
ConnectionEvent::Text(payload) => {
let error = match decode_response_interrupt(payload) {
Ok(Some(response_id)) => response_control.interrupt(&response_id).err().map(|_| {
super::super::RequestDecodeError::InvalidValue { field: "response_id".to_owned() }.protocol_body()
}),
Ok(None) => {
connection.defer(event);
continue;
}
Err(error) => Some(error.protocol_body()),
};
if let Some(error) = error
&& send_protocol_error(connection, StatusCode::BAD_REQUEST, error, request_id).await == ForwardOutcome::Disconnect
{
if is_response_create(payload) {
connection.defer(event);
continue;
}
if send_control(connection, response_control, payload, request_id).await == ForwardOutcome::Disconnect {
return BodyInput::Disconnect;
}
}
@@ -337,6 +344,11 @@ async fn next_body_input(
ConnectionEvent::Exited(_) => return BodyInput::Disconnect,
}
}
event = response_control.receive(), if terminal_seen => {
if forward_control_event(connection, response_control, event).await == ForwardOutcome::Disconnect {
return BodyInput::Disconnect;
}
}
frame = &mut frame => return BodyInput::Frame(frame),
}
}
@@ -537,19 +549,26 @@ impl WebSocketExecutionBody {
self.transformed_pending = transformed;
continue;
}
messages.extend(encoded.into_iter().map(|message| (message, transformed)));
let framing = if event.wire_event().is_some_and(|wire| {
wire.raw_websocket_message().is_some() && !wire.has_json_data()
}) {
MiddlewareFraming::RawBytes
} else {
MiddlewareFraming::JsonDocument
};
messages.extend(
encoded
.into_iter()
.map(|message| (message, transformed, framing)),
);
self.transformed_pending = false;
}
let terminal = self.encoder.is_completed() || self.encoder.has_wire_failure();
let last = messages.len().saturating_sub(1);
self.pending.extend(messages.into_iter().enumerate().map(
|(index, (message, transformed))| {
MiddlewareFrame::new(
Bytes::from(message),
MiddlewareFraming::JsonDocument,
terminal && index == last,
)
.with_transformed(transformed)
|(index, (message, transformed, framing))| {
MiddlewareFrame::new(Bytes::from(message), framing, terminal && index == last)
.with_transformed(transformed)
},
));
self.awaiting_terminal_eof = terminal;
@@ -720,3 +739,46 @@ async fn send_metadata(
.await
.is_ok()
}
pub(super) async fn send_control(
connection: &mut ResponsesWebSocketConnection,
control: &ResponseControl,
payload: &str,
request_id: &Arc<str>,
) -> ForwardOutcome {
let result = tokio::select! {
_ = connection.wait_for_exit() => return ForwardOutcome::Disconnect,
result = control.send(payload) => result,
};
if result.is_err() {
return send_gateway_error(
connection,
&GatewayError::new(
GatewayErrorKind::InvalidRequest,
"No upstream WebSocket connection is available for control messages",
),
request_id,
)
.await;
}
ForwardOutcome::Continue
}
pub(super) async fn forward_control_event(
connection: &mut ResponsesWebSocketConnection,
control: &ResponseControl,
event: Result<String, ResponseControlUnavailable>,
) -> ForwardOutcome {
let Ok(payload) = event else {
control.clear();
return ForwardOutcome::Continue;
};
if connection
.send_text(payload, WriteContext::connection(FramePhase::Data))
.await
.is_err()
{
return ForwardOutcome::Disconnect;
}
ForwardOutcome::Continue
}
@@ -24,6 +24,7 @@ use gateway_core::{
engine::{
execution::{AuthenticatedClient, ClientTransport},
middleware::{FrozenMiddlewarePlan, MiddlewareError},
response_control::ResponseControl,
},
lifecycle::{ConnectionGuard, ConnectionLifecycle},
operation::OperationKind,
@@ -46,11 +47,12 @@ use super::{
};
use connection::{ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext};
use forward::{
ConnectionReplaySnapshot, ForwardOutcome, execution_response, forward_response,
new_replay_capture, send_gateway_error, send_middleware_error, send_protocol_error,
ConnectionReplaySnapshot, ForwardOutcome, execution_response, forward_control_event,
forward_response, new_replay_capture, send_control, send_gateway_error, send_middleware_error,
send_protocol_error,
};
use protocol::connection_limit_event;
pub use protocol::{ResponseCreateFrameError, decode_response_create_with_context};
use protocol::{connection_limit_event, is_response_create};
const TEXT_FRAMES_ONLY: &str = "Responses WebSocket accepts text frames only";
const CONNECTION_LIMIT_CLOSE_REASON: &str = "Responses websocket connection limit reached";
@@ -191,10 +193,20 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
);
let mut request_count = 0_u64;
let mut replay = ConnectionReplaySnapshot::default();
let mut response_control = ResponseControl::default();
loop {
let Some(event) = connection.next_event().await else {
break;
let event = tokio::select! {
event = connection.next_event() => {
let Some(event) = event else { break };
event
}
event = response_control.receive() => {
if forward_control_event(&mut connection, &response_control, event).await == ForwardOutcome::Disconnect {
break;
}
continue;
}
};
let payload = match event {
ConnectionEvent::Text(payload) => payload,
@@ -212,8 +224,22 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
expire_connection(&mut connection).await;
break;
}
request_count = request_count.saturating_add(1);
let correlation_id = Arc::<str>::from(service.next_request_id());
if !is_response_create(&payload) {
if send_control(
&mut connection,
&response_control,
&payload,
&correlation_id,
)
.await
== ForwardOutcome::Disconnect
{
break;
}
continue;
}
request_count = request_count.saturating_add(1);
// 初步解码只保留路由事实,避免整份正文跨越准入等待和响应交付
let model_hint = match decode_response_create_with_context(&payload, &request_headers) {
Ok(decoded) => Some(decoded.metadata().requested_model().to_owned()),
@@ -251,6 +277,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
expire_connection(&mut connection).await;
break;
}
response_control.clear();
let execution = service.execution();
let preparation = async {
// 握手的显式改写可继承,Key 策略与宿主设置仍在每轮执行前刷新
@@ -291,7 +318,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
}
};
let request_id = Arc::<str>::from(prepared.request_id().to_string());
let response_control = prepared.response_control();
response_control = prepared.response_control();
let capture = new_replay_capture();
let validation = ResponseValidationFacts::default();
let input = RequestInput {
@@ -381,7 +408,7 @@ async fn serve_responses_websocket(socket: WebSocket, session: ResponsesWebSocke
&mut replay,
capture,
validation,
response_control,
&response_control,
)
.await
== ForwardOutcome::Disconnect
@@ -31,42 +31,22 @@ pub fn decode_response_create_with_context(
decode_response_create_inner(payload, request_headers)
}
/// 中断只引用活动响应;普通创建帧仍交给原有解码与串行准入路径
pub(super) fn decode_response_interrupt(
payload: &str,
) -> Result<Option<String>, ResponseCreateFrameError> {
/// 只识别需要本地准入的创建帧,其余文本不在网关解释
pub(super) fn is_response_create(payload: &str) -> bool {
#[derive(serde::Deserialize)]
struct FrameType {
#[serde(rename = "type")]
message_type: Option<String>,
message_type: String,
}
// 非控制帧仍在原来的串行请求边界校验,不能抢先拒绝排队的普通请求
// 只投影类型,避免为排队的大型 response.create 再构造完整 JSON 树
let Ok(frame) = serde_json::from_str::<FrameType>(payload) else {
return Ok(None);
};
if frame.message_type.as_deref() != Some("response.interrupt") {
return Ok(None);
match serde_json::from_str::<FrameType>(payload) {
Ok(frame) => frame.message_type == "response.create",
// struct 投影拒绝重复 type;沿用创建解码器的 JSON object 语义,
// 避免这类创建请求绕过准入进入透传路径
Err(_) => serde_json::from_str::<Value>(payload).is_ok_and(|frame| {
frame.get("type").and_then(Value::as_str) == Some("response.create")
}),
}
let value: Value =
serde_json::from_str(payload).map_err(|_| ResponseCreateFrameError::InvalidJson)?;
if value.get("mode").and_then(Value::as_str) != Some("discard_partial_items") {
return Err(ResponseCreateFrameError::Request(
RequestDecodeError::InvalidValue {
field: "mode".to_owned(),
},
));
}
let id = value
.get("response_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
.ok_or_else(|| {
ResponseCreateFrameError::Request(RequestDecodeError::InvalidValue {
field: "response_id".to_owned(),
})
})?;
Ok(Some(id.to_owned()))
}
fn decode_response_create_inner(
@@ -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,
+215 -2
View File
@@ -1,6 +1,6 @@
//! 验证健康接口按基础设施与关键 Worker 状态判定可用性
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use axum::{
body::Body,
@@ -13,12 +13,17 @@ use gateway_core::{
StartProviderExecution, StartedExecution,
},
error::GatewayError,
health::{WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState},
health::{
HealthProbe, HealthState, WorkerHealthKey, WorkerHealthSnapshot, WorkerHealthSource,
WorkerRuntimeState,
},
routing::PublicModelId,
task::{WorkerId, WorkerKind},
};
use tower::ServiceExt;
use crate::support::RecordingDiagnostics;
#[tokio::test]
async fn healthz_should_return_no_content_when_all_inputs_are_healthy() {
let response = crate::openai::api_router(Arc::new(UnusedExecution))
@@ -81,6 +86,214 @@ async fn healthz_should_reject_critical_worker_failure() {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test]
async fn healthz_records_probe_changes_once_and_records_recurrence_after_recovery() {
let probe = Arc::new(MutableProbe(Mutex::new(HealthState::Unhealthy(
"file logging rejected records".to_owned(),
))));
let diagnostics = Arc::new(RecordingDiagnostics::default());
let router = health_router(vec![probe.clone()], Vec::new(), diagnostics.clone()).await;
let responses =
futures::future::join_all((0..8).map(|_| health_response(router.clone()))).await;
for response in responses {
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
axum::body::to_bytes(response.into_body(), 1024)
.await
.unwrap()
.is_empty()
);
}
{
let events = diagnostics.0.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].component, "health");
assert_eq!(
events[0].correlation_id.as_deref(),
Some("probe:file_logging")
);
assert_eq!(events[0].message, "Health check failed");
assert!(
events[0]
.details
.as_ref()
.unwrap()
.as_str()
.contains("file logging rejected records")
);
}
*probe.0.lock().unwrap() = HealthState::Degraded("file logging maintenance failed".to_owned());
assert_eq!(
health_response(router.clone()).await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(diagnostics.0.lock().unwrap().len(), 2);
*probe.0.lock().unwrap() = HealthState::Healthy;
assert_eq!(
health_response(router.clone()).await.status(),
StatusCode::NO_CONTENT
);
*probe.0.lock().unwrap() = HealthState::Degraded("file logging maintenance failed".to_owned());
assert_eq!(
health_response(router).await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(diagnostics.0.lock().unwrap().len(), 3);
}
#[tokio::test(start_paused = true)]
async fn healthz_retains_worker_and_completed_probe_failures_when_another_probe_times_out() {
let diagnostics = Arc::new(RecordingDiagnostics::default());
let router = health_router(
vec![
Arc::new(MutableProbe(Mutex::new(HealthState::Unhealthy(
"file output failed".to_owned(),
)))),
Arc::new(PendingProbe),
],
vec![
worker_snapshot(
WorkerKind::RuntimeSnapshotReconciliation,
WorkerRuntimeState::BackingOff,
),
worker_snapshot(
WorkerKind::QuotaCatalogHealth,
WorkerRuntimeState::BackingOff,
),
],
diagnostics.clone(),
)
.await;
assert_eq!(
health_response(router).await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
let events = diagnostics.0.lock().unwrap();
assert_eq!(events.len(), 3);
for (component, reason) in [
(
"worker:runtime_snapshot_reconciliation:health-test",
"test worker failure",
),
("probe:file_logging", "file output failed"),
("probe:pending", "timed_out"),
] {
let event = events
.iter()
.find(|event| event.correlation_id.as_deref() == Some(component))
.expect(component);
assert!(event.details.as_ref().unwrap().as_str().contains(reason));
}
}
#[tokio::test]
async fn healthz_retries_failed_diagnostic_admission_without_changing_health_result() {
let diagnostics = Arc::new(FailingOnceDiagnostics::default());
let router = health_router(
vec![Arc::new(MutableProbe(Mutex::new(HealthState::Unhealthy(
"file output failed".to_owned(),
))))],
Vec::new(),
diagnostics.clone(),
)
.await;
assert_eq!(
health_response(router.clone()).await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert!(diagnostics.recorded.0.lock().unwrap().is_empty());
assert_eq!(
health_response(router).await.status(),
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(diagnostics.recorded.0.lock().unwrap().len(), 1);
}
#[derive(Default)]
struct FailingOnceDiagnostics {
failed: std::sync::atomic::AtomicBool,
recorded: RecordingDiagnostics,
}
#[async_trait::async_trait]
impl gateway_core::diagnostics::OperationalDiagnostics for FailingOnceDiagnostics {
async fn record_failure(
&self,
failure: gateway_core::diagnostics::OperationalFailure,
) -> Result<(), gateway_core::error::StoreError> {
if !self.failed.swap(true, std::sync::atomic::Ordering::SeqCst) {
return Err(gateway_core::error::StoreError::new(
gateway_core::error::StoreErrorKind::Unavailable,
));
}
self.recorded.record_failure(failure).await
}
}
async fn health_router(
probes: Vec<Arc<dyn HealthProbe>>,
workers: Vec<WorkerHealthSnapshot>,
diagnostics: Arc<dyn gateway_core::diagnostics::OperationalDiagnostics>,
) -> axum::Router {
let admin = crate::admin::AdminTestFixture::new().await;
gateway_api::initialize(
gateway_api::ApiConfig {
asset_directory: std::env::temp_dir(),
cors_allowed_origins: Vec::new(),
request_timeout_seconds: None,
request_id_header: "x-request-id".to_owned(),
},
Arc::new(UnusedExecution),
admin.services,
probes,
Arc::new(StaticWorkerHealth(workers)),
Arc::new(crate::openai::TestLifecycle::default()),
diagnostics,
)
.unwrap()
.router()
}
async fn health_response(router: axum::Router) -> axum::response::Response {
router
.oneshot(
Request::builder()
.uri("/healthz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
}
struct MutableProbe(Mutex<HealthState>);
impl HealthProbe for MutableProbe {
fn name(&self) -> &'static str {
"file_logging"
}
fn check(&self) -> BoxFuture<'_, HealthState> {
Box::pin(std::future::ready(self.0.lock().unwrap().clone()))
}
}
struct PendingProbe;
impl HealthProbe for PendingProbe {
fn name(&self) -> &'static str {
"pending"
}
fn check(&self) -> BoxFuture<'_, HealthState> {
Box::pin(std::future::pending())
}
}
#[derive(Clone)]
struct StaticWorkerHealth(Vec<WorkerHealthSnapshot>);
@@ -10,6 +10,7 @@ use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header::AUTHORIZATION},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::execution::{
@@ -300,6 +301,11 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio
.header("x-codex-image-turn-id", "turn_image_route")
.header("session-id", "root-image-session")
.header("thread-id", "child-image-thread")
.header("x-future-business", "first")
.header("x-future-business", "second")
.header("cookie", "client-secret")
.header("connection", "x-private-hop")
.header("x-private-hop", "client-hop")
.body(Body::from(body.to_vec()))
.expect("image request"),
)
@@ -338,8 +344,37 @@ async fn image_routes_should_not_decode_bodies_and_should_preserve_both_directio
assert_eq!(captured.transport, ClientTransport::HttpJson);
assert_eq!(captured.kind, *kind);
assert_eq!(captured.body.as_ref(), *body);
let mut context = captured.context.clone();
let headers = context
.as_object_mut()
.unwrap()
.remove("opaque_request_headers")
.unwrap();
let headers: Vec<_> = headers
.as_array()
.unwrap()
.iter()
.map(|entry| {
(
entry[0].as_str().unwrap(),
STANDARD.decode(entry[1].as_str().unwrap()).unwrap(),
)
})
.collect();
assert_eq!(
captured.context,
headers
.iter()
.filter(|(name, _)| *name == "x-future-business")
.map(|(_, value)| value.as_slice())
.collect::<Vec<_>>(),
[b"first".as_slice(), b"second".as_slice()]
);
assert!(!headers.iter().any(|(name, _)| matches!(
*name,
"authorization" | "cookie" | "connection" | "x-private-hop"
)));
assert_eq!(
context,
json!({"image_turn_id": "turn_image_route", "session_id": "root-image-session", "thread_id": "child-image-thread"})
);
}
@@ -323,7 +323,7 @@ fn snapshot_with_client_key(
}
#[derive(Default)]
struct TestLifecycle {
pub(crate) struct TestLifecycle {
cancellation: gateway_core::lifecycle::CancellationToken,
}
@@ -3022,3 +3022,67 @@ async fn compressed_http_request_above_default_limit_should_reach_execution_afte
}
}
}
#[tokio::test]
async fn streaming_middleware_preserves_heartbeats_before_and_after_rewriting() {
for opaque in [
b": ping\n\n".as_slice(),
b"id: next\nretry: 1000\n\n".as_slice(),
b"event: vendor.future\ndata: future non-JSON payload\n\n".as_slice(),
] {
let admin = crate::admin::AdminTestFixture::new().await.services;
let trace = Arc::new(Trace::default());
let heartbeat = Bytes::copy_from_slice(opaque);
let future = Bytes::from_static(
b"event: response.future\ndata: {\"type\":\"response.future\",\"extension\":true}\n\n",
);
let session = FakeSession::streaming(
Arc::clone(&trace),
vec![
NextStep::Event(delivery(started(), CommitRequirement::CommitBeforeDelivery)),
NextStep::Event(delivery_provider(
ProviderEvent::wire(
ProtocolWireEvent::raw_sse("openai", heartbeat.clone()).unwrap(),
),
CommitRequirement::AlreadyCommitted,
)),
NextStep::Event(delivery_provider(
ProviderEvent::wire(
ProtocolWireEvent::raw_sse("openai", future.clone()).unwrap(),
),
CommitRequirement::AlreadyCommitted,
)),
NextStep::Event(delivery_provider(
ProviderEvent::wire(
ProtocolWireEvent::raw_sse("openai", heartbeat.clone()).unwrap(),
),
CommitRequirement::AlreadyCommitted,
)),
NextStep::Event(delivery(completed(), CommitRequirement::AlreadyCommitted)),
NextStep::FinalizeSuccess,
],
);
let replacement = Bytes::from_static(b"event: response.future\ndata: {\"type\":\"response.future\",\"extension\":\"changed\"}\n\n");
let middleware = Arc::new(responses_middleware().with_response_actions(vec![
ResponseFrameAction::Continue,
ResponseFrameAction::Continue,
ResponseFrameAction::Replace(replacement.clone()),
ResponseFrameAction::Continue,
ResponseFrameAction::Continue,
ResponseFrameAction::Continue,
]));
let response =
response_with_middleware(&admin, "openai", session, true, Some(middleware)).await;
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert_eq!(
text.matches(std::str::from_utf8(opaque).unwrap()).count(),
2
);
assert!(text.contains(std::str::from_utf8(&replacement).unwrap()));
assert!(text.contains("response.completed"));
assert!(!text.contains("middleware_failed"));
assert!(text.ends_with("data: [DONE]\n\n"));
assert!(!trace.is_cancelled());
}
}
@@ -1071,3 +1071,35 @@ fn transparent_encoder_should_only_require_wire_terminal_for_buffered_conversion
ResponseEncodeError::MissingWireTerminal
);
}
#[test]
fn metadata_keeps_business_extensions_and_filters_only_protected_headers() {
let data = json!({
"type":"response.metadata",
"headers":{"x-future-header":["first","second"],"authorization":"private","set-cookie":"private","chatgpt-account-id":"private","connection":"x-private-hop","x-private-hop":"private"},
"metadata":{"type":"safety_buffering","use_cases":["cyber"],"reasons":["user_risk"],"openai_verification_recommendation":{"future":true},"openai_chatgpt_moderation_metadata":{"future":true}},
"future_extension":{"keep":true}
});
let raw = data.to_string();
let event = ProviderEvent::wire(
ProtocolWireEvent::json("openai", Some("response.metadata".to_owned()), data.clone())
.unwrap()
.with_raw_websocket_message(raw.as_str()),
);
let mut encoder = OpenAiResponsesEncoder::new();
let ws = encoder.push_websocket(&event);
let delivered: Value = serde_json::from_str(&ws[0]).unwrap();
assert_eq!(delivered["metadata"], data["metadata"]);
assert_eq!(delivered["future_extension"], data["future_extension"]);
assert_eq!(
delivered["headers"],
json!({"x-future-header":["first","second"]})
);
let sse = encoder.push_sse(&event);
assert!(!std::str::from_utf8(&sse[0]).unwrap().contains("private"));
assert!(
std::str::from_utf8(&sse[0])
.unwrap()
.contains("safety_buffering")
);
}
@@ -106,10 +106,18 @@ async fn next_error(socket: &mut TestSocket) -> Value {
}
async fn assert_no_duplicate_failure(socket: &mut TestSocket) {
// 后续入站帧只会在本轮收敛后处理,比固定等待窗口更可靠地检查没有重复失败终态
socket.send(Message::Text("{".into())).await.unwrap();
// 无效创建帧仍走串行队列,以本地业务校验作为本轮收敛后的屏障
socket
.send(Message::Text(
json!({"type":"response.create","model":"model-a","stream":false})
.to_string()
.into(),
))
.await
.unwrap();
let error = next_event(socket).await;
assert_eq!(error["error"]["code"], "invalid_json");
assert_eq!(error["error"]["code"], "invalid_value");
assert_eq!(error["error"]["param"], "stream");
assert_eq!(error["status"], 400);
assert_eq!(error["headers"]["x-request-id"], error["request_id"]);
assert_eq!(
@@ -1,12 +1,70 @@
//! 验证 Responses WebSocket 中断向当前执行传递并释放相关资源
//! 验证未知控制消息、迟到中断、结算期间收发和创建请求的串行准入
use super::*;
use gateway_core::engine::response_control::{ResponseControl, ResponseInterruptError};
use gateway_core::engine::response_control::{
ResponseControlTransport, ResponseControlUnavailable,
};
struct ControlTransport {
id: String,
active: std::sync::atomic::AtomicBool,
requests: Mutex<Vec<String>>,
sender: tokio::sync::mpsc::Sender<Value>,
receiver: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<Value>>,
}
impl ControlTransport {
fn new(id: String) -> Self {
let (sender, receiver) = tokio::sync::mpsc::channel(32);
Self {
id,
active: std::sync::atomic::AtomicBool::new(true),
requests: Mutex::default(),
sender,
receiver: tokio::sync::Mutex::new(receiver),
}
}
}
#[async_trait]
impl ResponseControlTransport for ControlTransport {
async fn send(&self, payload: &str) -> Result<(), ResponseControlUnavailable> {
self.requests.lock().unwrap().push(payload.to_owned());
let original: Value =
serde_json::from_str(payload).unwrap_or_else(|_| Value::String(payload.to_owned()));
let event = if original["type"] == "future.reject" {
upstream_control_error()
} else if original["type"] == "response.interrupt"
&& original["response_id"] == self.id
&& original["mode"] == "discard_partial_items"
&& self.active.load(Ordering::SeqCst)
{
json!({"type":"response.incomplete", "response":{"id":self.id,"model":"model-a","status":"incomplete","output":[],"incomplete_details":{"reason":"interrupted"}}})
} else {
json!({"type":"future.control.ack", "original":original})
};
self.sender
.send(event)
.await
.map_err(|_| ResponseControlUnavailable)
}
async fn receive(&self) -> Result<String, ResponseControlUnavailable> {
self.receiver
.lock()
.await
.recv()
.await
.map(|event| event.to_string())
.ok_or(ResponseControlUnavailable)
}
}
#[derive(Default)]
struct InterruptProvider {
calls: AtomicUsize,
controls: Mutex<Vec<ResponseControl>>,
transport: Mutex<Option<Arc<ControlTransport>>>,
complete: tokio::sync::Notify,
initial_messages: Mutex<std::collections::VecDeque<ProtocolWireEvent>>,
}
#[async_trait]
@@ -30,12 +88,10 @@ impl Provider for InterruptProvider {
) -> Result<ProviderStream, ProviderError> {
let number = self.calls.fetch_add(1, Ordering::SeqCst);
let id = format!("resp_interrupt_{number}");
let control = context
.response_control()
.expect("root control propagated through Core")
.clone();
let active = control.activate(id.clone()).unwrap();
self.controls.lock().unwrap().push(control);
let transport = Arc::new(ControlTransport::new(id));
let owner: Arc<dyn ResponseControlTransport> = transport.clone();
context.response_control().unwrap().bind(&owner);
*self.transport.lock().unwrap() = Some(transport.clone());
let candidate = request.candidate();
let metadata = ProviderCallMetadata::new(
candidate.provider().clone(),
@@ -44,41 +100,55 @@ impl Provider for InterruptProvider {
UpstreamTransport::new("websocket").unwrap(),
);
let body = futures::stream::unfold(
(0, Some(active), id),
|(phase, mut active, id)| async move {
let (event_type, status, event) = match phase {
(0, transport, self),
|(phase, transport, provider)| async move {
let pending = if phase == 1 {
provider.initial_messages.lock().unwrap().pop_front()
} else {
None
};
if let Some(wire) = pending {
return Some((Ok(ProviderEvent::wire(wire)), (phase, transport, provider)));
}
let (wire, events, next_phase) = match phase {
0 => (
"response.created",
"in_progress",
GatewayEvent::Started(ResponseMeta::new(&id, "model-a")),
json!({"type":"response.created","response":{"id":transport.id,"model":"model-a","status":"in_progress","output":[]}}),
vec![GatewayEvent::Started(ResponseMeta::new(
&transport.id,
"model-a",
))],
1,
),
1 => {
active.as_ref().unwrap().requested().await;
drop(active.take());
(
"response.incomplete",
"incomplete",
GatewayEvent::Completed(ResponseMeta::new(&id, "model-a")),
)
let wire = tokio::select! {
reply = transport.receive() => serde_json::from_str::<Value>(&reply.unwrap()).unwrap(),
() = provider.complete.notified() => json!({"type":"response.completed","response":{"id":transport.id,"model":"model-a","status":"completed","output":[]}}),
};
if matches!(
wire["type"].as_str(),
Some("response.completed" | "response.incomplete")
) {
transport.active.store(false, Ordering::SeqCst);
(
wire,
vec![GatewayEvent::Completed(ResponseMeta::new(
&transport.id,
"model-a",
))],
2,
)
} else {
(wire, Vec::new(), 1)
}
}
_ => return None,
};
let mut response = json!({"id":id,"model":"model-a","status":status,"output":[]});
if phase == 1 {
response["incomplete_details"] = json!({"reason":"interrupted"});
}
let event_type = wire["type"].as_str().unwrap().to_owned();
let event = ProviderEvent::canonical_with_wire(
vec![event],
ProtocolWireEvent::json(
"openai",
Some(event_type.to_owned()),
json!({
"type":event_type, "response":response
}),
)
.unwrap(),
events,
ProtocolWireEvent::json("openai", Some(event_type), wire).unwrap(),
);
Some((Ok(event), (phase + 1, active, id)))
Some((Ok(event), (next_phase, transport, provider)))
},
);
Ok(ProviderStream::new(metadata, body, ()))
@@ -88,6 +158,8 @@ impl Provider for InterruptProvider {
#[derive(Default)]
struct InterruptAdmissions {
active: AtomicUsize,
release_started: tokio::sync::Notify,
release_gate: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
}
impl ClientAdmissionPort for InterruptAdmissions {
@@ -109,6 +181,11 @@ impl ClientAdmissionPort for InterruptAdmissions {
_: &'a ModelRequestId,
) -> BoxFuture<'a, Result<bool, ClientAdmissionError>> {
Box::pin(async {
let gate = self.release_gate.lock().unwrap().take();
self.release_started.notify_one();
if let Some(gate) = gate {
gate.await.unwrap();
}
self.active.fetch_sub(1, Ordering::SeqCst);
Ok(true)
})
@@ -151,15 +228,15 @@ async fn next(socket: &mut TestSocket) -> Value {
}
}
#[tokio::test]
async fn interrupt_is_live_scoped_and_keeps_queued_creates_serial() {
let provider = Arc::new(InterruptProvider::default());
let admissions = Arc::new(InterruptAdmissions::default());
async fn connect(
provider: Arc<InterruptProvider>,
admissions: Arc<InterruptAdmissions>,
) -> (TestSocket, Server) {
let execution = Arc::new(DefaultExecutionService::new(
RuntimeSnapshotHandle::new(snapshot("sk_ws_interrupt", "openai")),
Arc::new(SettlementPorts::default()),
ProviderRegistry::new([provider.clone() as Arc<dyn Provider>]).unwrap(),
admissions.clone(),
ProviderRegistry::new([provider as Arc<dyn Provider>]).unwrap(),
admissions,
Arc::new(UnusedContinuation),
Arc::new(IgnoredClientApiKeyUsage),
Arc::new(crate::support::RecordingDiagnostics::default()),
@@ -167,7 +244,7 @@ async fn interrupt_is_live_scoped_and_keeps_queued_creates_serial() {
let app = api_router(execution).await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let _server = Server(tokio::spawn(async move {
let server = Server(tokio::spawn(async move {
axum::serve(listener, app).await.unwrap()
}));
let mut request = format!("ws://{address}/v1/responses")
@@ -177,29 +254,136 @@ async fn interrupt_is_live_scoped_and_keeps_queued_creates_serial() {
AUTHORIZATION,
HeaderValue::from_static("Bearer sk_ws_interrupt"),
);
let (mut socket, _) = connect_async(request).await.unwrap();
let (socket, _) = connect_async(request).await.unwrap();
(socket, server)
}
fn upstream_control_error() -> Value {
json!({"type":"error", "status":400, "error":{"type":"invalid_request_error","code":"future_rejected","message":"Synthetic upstream rejection","param":"type"},"vendor_extension":true})
}
fn interrupt(id: &str, mode: &str) -> Value {
json!({"type":"response.interrupt","response_id":id,"mode":mode,"future_extension":true})
}
#[tokio::test]
async fn late_interrupt_after_terminal_is_sent_upstream_and_keeps_the_connection_reusable() {
let provider = Arc::new(InterruptProvider::default());
let admissions = Arc::new(InterruptAdmissions::default());
let (mut socket, _server) = connect(provider.clone(), admissions.clone()).await;
let create = json!({"type":"response.create","model":"model-a","input":"hello"});
for (number, terminal) in [(0, "response.completed"), (1, "response.incomplete")] {
let id = format!("resp_interrupt_{number}");
send(&mut socket, create.clone()).await;
assert_eq!(next(&mut socket).await["type"], "response.created");
if number == 0 {
provider.complete.notify_one();
} else {
send(&mut socket, interrupt(&id, "discard_partial_items")).await;
}
assert_eq!(next(&mut socket).await["type"], terminal);
tokio::time::timeout(Duration::from_secs(5), async {
while admissions.active.load(Ordering::SeqCst) != 0 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
for control in [
interrupt(&id, "discard_partial_items"),
interrupt(&id, "future_mode"),
json!({"type":"future.control","extra":[1,2]}),
] {
send(&mut socket, control.clone()).await;
assert_eq!(
next(&mut socket).await,
json!({"type":"future.control.ack","original":control})
);
}
send(&mut socket, json!({"type":"future.reject"})).await;
assert_eq!(next(&mut socket).await, upstream_control_error());
assert_eq!(provider.calls.load(Ordering::SeqCst), number + 1);
assert_eq!(admissions.active.load(Ordering::SeqCst), 0);
}
socket.close(None).await.unwrap();
}
#[tokio::test]
async fn late_interrupt_during_terminal_settlement_does_not_abort_or_reorder_requests() {
let provider = Arc::new(InterruptProvider::default());
let (release, wait_for_release) = tokio::sync::oneshot::channel();
let admissions = Arc::new(InterruptAdmissions {
release_gate: Mutex::new(Some(wait_for_release)),
..Default::default()
});
let (mut socket, _server) = connect(provider.clone(), admissions.clone()).await;
let create = json!({"type":"response.create","model":"model-a","input":"hello"});
let interrupt =
|id: &str, mode: &str| json!({"type":"response.interrupt","response_id":id,"mode":mode});
send(
&mut socket,
interrupt("resp_interrupt_0", "discard_partial_items"),
)
.await;
assert_eq!(next(&mut socket).await["type"], "error");
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
send(&mut socket, create.clone()).await;
assert_eq!(next(&mut socket).await["type"], "response.created");
provider.complete.notify_one();
assert_eq!(next(&mut socket).await["type"], "response.completed");
tokio::time::timeout(
Duration::from_secs(5),
admissions.release_started.notified(),
)
.await
.unwrap();
send(&mut socket, create).await;
for invalid in [
interrupt("resp_other", "discard_partial_items"),
interrupt("resp_interrupt_0", "unknown"),
interrupt("", "discard_partial_items"),
let late = interrupt("resp_interrupt_0", "discard_partial_items");
send(&mut socket, late.clone()).await;
assert_eq!(
next(&mut socket).await,
json!({"type":"future.control.ack", "original":late})
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
assert_eq!(admissions.active.load(Ordering::SeqCst), 1);
release.send(()).unwrap();
assert_eq!(
next(&mut socket).await["response"]["id"],
"resp_interrupt_1"
);
socket.close(None).await.unwrap();
}
#[tokio::test]
async fn unknown_controls_preserve_bytes_and_do_not_admit_queued_creates() {
let provider = Arc::new(InterruptProvider::default());
let admissions = Arc::new(InterruptAdmissions::default());
let (mut socket, _server) = connect(provider.clone(), admissions.clone()).await;
let create = json!({"type":"response.create","model":"model-a","input":"hello"});
send(&mut socket, json!({"type":"future.control"})).await;
let error = next(&mut socket).await;
assert_eq!(error["type"], "error");
assert_ne!(error["error"]["param"], "type");
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
send(&mut socket, create).await;
assert_eq!(next(&mut socket).await["type"], "response.created");
// 重复 type 仍是创建请求,不能借投影解码失败绕过串行准入
socket.send(ClientMessage::Text(r#"{"type":"response.create","type":"response.create","model":"model-a","input":"next"}"#.into())).await.unwrap();
for raw in [
r#"{ "type": "future.control", "mode": "unknown", "extension": [1, 2] }"#,
r#"{"extension":"no local type contract"}"#,
r#"{"type":42,"extension":true}"#,
"not-json",
] {
send(&mut socket, invalid).await;
let error = next(&mut socket).await;
assert_eq!(error["type"], "error");
assert_eq!(error["status"], 400);
socket.send(ClientMessage::Text(raw.into())).await.unwrap();
let expected =
serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_owned()));
assert_eq!(next(&mut socket).await["original"], expected);
assert_eq!(
provider
.transport
.lock()
.unwrap()
.as_ref()
.unwrap()
.requests
.lock()
.unwrap()
.last()
.unwrap(),
raw
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
}
send(
@@ -207,27 +391,12 @@ async fn interrupt_is_live_scoped_and_keeps_queued_creates_serial() {
interrupt("resp_interrupt_0", "discard_partial_items"),
)
.await;
let terminal = next(&mut socket).await;
assert_eq!(terminal["type"], "response.incomplete");
assert_eq!(
terminal["response"]["incomplete_details"]["reason"],
"interrupted"
);
assert_eq!(next(&mut socket).await["type"], "response.incomplete");
assert_eq!(
next(&mut socket).await["response"]["id"],
"resp_interrupt_1"
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 2);
send(
&mut socket,
interrupt("resp_interrupt_0", "discard_partial_items"),
)
.await;
assert_eq!(next(&mut socket).await["type"], "error");
assert_eq!(
provider.controls.lock().unwrap()[0].interrupt("resp_interrupt_0"),
Err(ResponseInterruptError::Unavailable)
);
socket.close(None).await.unwrap();
tokio::time::timeout(Duration::from_secs(5), async {
while admissions.active.load(Ordering::SeqCst) != 0 {
@@ -236,8 +405,46 @@ async fn interrupt_is_live_scoped_and_keeps_queued_creates_serial() {
})
.await
.expect("disconnect releases active execution");
assert_eq!(
provider.controls.lock().unwrap()[1].interrupt("resp_interrupt_1"),
Err(ResponseInterruptError::Unavailable)
);
}
#[tokio::test]
async fn active_websocket_delivery_preserves_unparsed_messages_and_business_metadata() {
let messages = vec![
"{ \"type\": \"future.event\", \"number\": 1e3, \"escaped\": \"\\u0061\" }\r\n".to_owned(),
r#"{"type":"response.metadata","metadata":{"type":"safety_buffering","use_cases":["cyber"],"reasons":["user_risk"],"future":{"keep":true}}}"#.to_owned(),
format!("{{\"type\":\"future.deep\",\"extension\":{}0{}}}", "[".repeat(140), "]".repeat(140)),
"future non-JSON text".to_owned(), "[DONE]".to_owned(),
];
let provider = Arc::new(InterruptProvider::default());
*provider.initial_messages.lock().unwrap() = messages
.iter()
.map(|raw| match serde_json::from_str::<Value>(raw) {
Ok(value) => ProtocolWireEvent::json(
"openai",
value.get("type").and_then(Value::as_str).map(str::to_owned),
value,
)
.unwrap()
.with_raw_websocket_message(raw.as_str()),
Err(_) => ProtocolWireEvent::raw_websocket("openai", raw.as_str()).unwrap(),
})
.collect();
let (mut socket, _server) =
connect(provider.clone(), Arc::new(InterruptAdmissions::default())).await;
send(
&mut socket,
json!({"type":"response.create","model":"model-a","input":"synthetic"}),
)
.await;
assert_eq!(next(&mut socket).await["type"], "response.created");
for expected in &messages {
let actual = tokio::time::timeout(Duration::from_secs(5), socket.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(actual.to_text().unwrap(), expected);
}
provider.complete.notify_one();
assert_eq!(next(&mut socket).await["type"], "response.completed");
}
@@ -7,6 +7,7 @@ use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header::AUTHORIZATION},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use bytes::Bytes;
use futures::future::BoxFuture;
use gateway_core::engine::ModelRequestId;
@@ -181,6 +182,11 @@ async fn search_route_should_preserve_request_and_success_response_bytes() {
.header("x-codex-turn-metadata", turn_metadata)
.header("session-id", "root-session")
.header("thread-id", "child-thread")
.header("x-future-business", "first")
.header("x-future-business", "second")
.header("cookie", "client-secret")
.header("connection", "x-private-hop")
.header("x-private-hop", "client-hop")
.body(Body::from(request_body.to_vec()))
.expect("search request"),
)
@@ -213,8 +219,37 @@ async fn search_route_should_preserve_request_and_success_response_bytes() {
assert_eq!(captured[0].endpoint, "/v1/alpha/search");
assert_eq!(captured[0].transport, ClientTransport::HttpJson);
assert_eq!(captured[0].body.as_ref(), request_body);
let mut context = captured[0].context.clone();
let headers = context
.as_object_mut()
.unwrap()
.remove("opaque_request_headers")
.unwrap();
let headers: Vec<_> = headers
.as_array()
.unwrap()
.iter()
.map(|entry| {
(
entry[0].as_str().unwrap(),
STANDARD.decode(entry[1].as_str().unwrap()).unwrap(),
)
})
.collect();
assert_eq!(
captured[0].context,
headers
.iter()
.filter(|(name, _)| *name == "x-future-business")
.map(|(_, value)| value.as_slice())
.collect::<Vec<_>>(),
[b"first".as_slice(), b"second".as_slice()]
);
assert!(!headers.iter().any(|(name, _)| matches!(
*name,
"authorization" | "cookie" | "connection" | "x-private-hop"
)));
assert_eq!(
context,
json!({"turn_metadata": turn_metadata, "session_id": "root-session", "thread_id": "child-thread"})
);
assert_eq!(
@@ -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()
@@ -605,10 +605,15 @@ fn provider_event_to_middleware_frame(
ClientTransport::WebSocket
| ClientTransport::InternalProbe
| ClientTransport::InternalPlugin => (
serde_json::to_vec(wire.data())
.map(Bytes::from)
wire.raw_websocket_message()
.map(|raw| Bytes::copy_from_slice(raw.as_bytes()))
.map_or_else(|| serde_json::to_vec(wire.data()).map(Bytes::from), Ok)
.map_err(|_| middleware_protocol_error(UpstreamSendState::Sent))?,
MiddlewareFraming::JsonDocument,
if wire.raw_websocket_message().is_some() && !wire.has_json_data() {
MiddlewareFraming::RawBytes
} else {
MiddlewareFraming::JsonDocument
},
),
};
Ok(MiddlewareFrame::from_provider_event(
@@ -631,10 +636,19 @@ fn encode_sse_wire_event(wire: &ProtocolWireEvent) -> Result<Bytes, ProviderErro
encoded.extend_from_slice(event_type.as_bytes());
encoded.push(b'\n');
}
encoded.extend_from_slice(b"data: ");
serde_json::to_writer(&mut encoded, wire.data())
.map_err(|_| middleware_protocol_error(UpstreamSendState::Sent))?;
encoded.extend_from_slice(b"\n\n");
if let Some(raw) = wire.raw_websocket_message() {
for line in raw.split('\n') {
encoded.extend_from_slice(b"data: ");
encoded.extend_from_slice(line.as_bytes());
encoded.push(b'\n');
}
encoded.push(b'\n');
} else {
encoded.extend_from_slice(b"data: ");
serde_json::to_writer(&mut encoded, wire.data())
.map_err(|_| middleware_protocol_error(UpstreamSendState::Sent))?;
encoded.extend_from_slice(b"\n\n");
}
Ok(Bytes::from(encoded))
}
@@ -701,12 +715,19 @@ fn middleware_frame_to_provider_event(
.raw_json_body()
.or_else(|| wire.raw_http_body_bytes())
.cloned()
.or_else(|| {
wire.raw_websocket_message()
.map(|raw| Bytes::copy_from_slice(raw.as_bytes()))
})
.or_else(|| serde_json::to_vec(wire.data()).ok().map(Bytes::from)),
MiddlewareFraming::SseEvent => wire
.raw_sse_frame()
.cloned()
.or_else(|| encode_sse_wire_event(wire).ok()),
MiddlewareFraming::RawBytes => wire.raw_http_body_bytes().cloned(),
MiddlewareFraming::RawBytes => wire.raw_http_body_bytes().cloned().or_else(|| {
wire.raw_websocket_message()
.map(|raw| Bytes::copy_from_slice(raw.as_bytes()))
}),
};
if original.as_ref() == Some(&bytes)
&& let Some(mut event) = envelope.take()
@@ -944,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,
@@ -1176,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)
}
}
@@ -1,14 +1,27 @@
//! 当前执行的响应中断信号;只由持有活动响应的 Provider 注册,不查找其他执行或连接
//! 原上游连接的控制消息端口;不解释协议类型、不创建执行或延长连接生命周期
use std::fmt;
use std::sync::{Arc, Mutex, Weak};
use crate::lifecycle::CancellationToken;
use async_trait::async_trait;
/// 单次根执行的控制入口;重试不继承已结束响应的控制目标
/// Provider 实现的连接控制通道,消息内容由上游协议解释
#[async_trait]
pub trait ResponseControlTransport: Send + Sync {
async fn send(&self, payload: &str) -> Result<(), ResponseControlUnavailable>;
/// 仅在响应正文已结束后读取,调用方必须先取消此读取再开始下一轮执行
async fn receive(&self) -> Result<String, ResponseControlUnavailable>;
}
/// 控制消息没有可用的原上游连接,不能通过重新选号或重试恢复
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResponseControlUnavailable;
/// 单次根执行绑定的原连接;终态后仍可使用,连接销毁或重新绑定时失效
#[derive(Clone, Default)]
pub struct ResponseControl {
active: Arc<Mutex<Weak<InterruptTarget>>>,
target: Arc<Mutex<Option<Weak<dyn ResponseControlTransport>>>>,
}
impl fmt::Debug for ResponseControl {
@@ -17,64 +30,42 @@ impl fmt::Debug for ResponseControl {
}
}
struct InterruptTarget {
response_id: String,
requested: CancellationToken,
}
/// Provider 持有的活动响应控制权;释放后客户端无法再向该响应发出中断
pub struct ActiveResponseInterrupt(Arc<InterruptTarget>);
/// 控制帧不能作用于当前执行时的稳定原因,不携带其他响应身份
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResponseInterruptError {
Unavailable,
ResponseMismatch,
}
impl ResponseControl {
/// 在上游确认响应 ID 后注册;同一执行只允许一个存活的响应 owner
pub fn activate(&self, response_id: String) -> Option<ActiveResponseInterrupt> {
let mut active = self
.active
/// Provider 在请求成功写入上游后绑定;连接 owner 持有强引用并负责撤销
pub fn bind(&self, transport: &Arc<dyn ResponseControlTransport>) {
*self
.target
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if active.upgrade().is_some() || response_id.is_empty() {
return None;
}
let target = Arc::new(InterruptTarget {
response_id,
requested: CancellationToken::new(),
});
*active = Arc::downgrade(&target);
Some(ActiveResponseInterrupt(target))
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::downgrade(transport));
}
/// 重复中断合并为一次信号;ID 必须匹配本执行当前的上游响应
pub fn interrupt(&self, response_id: &str) -> Result<(), ResponseInterruptError> {
let active = self
.active
pub fn clear(&self) {
*self
.target
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let target = active
.upgrade()
.ok_or(ResponseInterruptError::Unavailable)?;
if target.response_id != response_id {
return Err(ResponseInterruptError::ResponseMismatch);
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
fn transport(&self) -> Option<Arc<dyn ResponseControlTransport>> {
self.target
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.and_then(Weak::upgrade)
}
pub async fn send(&self, payload: &str) -> Result<(), ResponseControlUnavailable> {
self.transport()
.ok_or(ResponseControlUnavailable)?
.send(payload)
.await
}
/// 未绑定时保持等待,供连接空闲循环与客户端输入共同 select
pub async fn receive(&self) -> Result<String, ResponseControlUnavailable> {
match self.transport() {
Some(transport) => transport.receive().await,
None => std::future::pending().await,
}
target.requested.cancel();
Ok(())
}
}
impl ActiveResponseInterrupt {
pub fn response_id(&self) -> &str {
&self.0.response_id
}
pub fn requested(&self) -> impl Future<Output = ()> + Send + 'static {
// 等待者只持有信号,不能延长活动响应 owner 的生命周期
let requested = self.0.requested.clone();
async move { requested.cancelled().await }
}
}
+37 -1
View File
@@ -203,6 +203,7 @@ pub struct ProtocolWireEvent {
data: Value,
has_json_data: bool,
raw_sse_frame: Option<Bytes>,
raw_websocket_message: Option<Arc<str>>,
raw_json_body: Option<Bytes>,
raw_http_body: Option<Bytes>,
sse_id: Option<String>,
@@ -245,6 +246,7 @@ impl ProtocolWireEvent {
data,
has_json_data: true,
raw_sse_frame: None,
raw_websocket_message: None,
raw_json_body: None,
raw_http_body: None,
sse_id,
@@ -293,6 +295,7 @@ impl ProtocolWireEvent {
data: Value::Null,
has_json_data: false,
raw_sse_frame: Some(raw_sse_frame),
raw_websocket_message: None,
raw_json_body: None,
raw_http_body: None,
sse_id: None,
@@ -300,6 +303,33 @@ impl ProtocolWireEvent {
})
}
/// 创建原始 WebSocket 文本;JSON 解析失败不影响交付
///
/// # Errors
///
/// 协议名不满足 wire 安全约束时返回错误
pub fn raw_websocket(
protocol: impl Into<String>,
message: impl Into<Arc<str>>,
) -> Result<Self, IdentifierError> {
let mut wire = Self::json(protocol, None, Value::Null)?;
wire.has_json_data = false;
Ok(wire.with_raw_websocket_message(message))
}
/// 绑定同一事件未经改写的 WebSocket 文本,解析视图只用于旁路事实
#[must_use]
pub fn with_raw_websocket_message(mut self, message: impl Into<Arc<str>>) -> Self {
self.raw_websocket_message = Some(message.into());
self
}
/// 返回未经改写的 WebSocket 文本
#[must_use]
pub fn raw_websocket_message(&self) -> Option<&str> {
self.raw_websocket_message.as_deref()
}
/// 创建未经改写的完整 JSON 响应正文
///
/// 该载荷用于非流式协议端点;Core 不解析或重编码其中的值
@@ -319,6 +349,7 @@ impl ProtocolWireEvent {
data: Value::Null,
has_json_data: false,
raw_sse_frame: None,
raw_websocket_message: None,
raw_json_body: Some(raw_json_body),
raw_http_body: None,
sse_id: None,
@@ -343,6 +374,7 @@ impl ProtocolWireEvent {
data: Value::Null,
has_json_data: false,
raw_sse_frame: None,
raw_websocket_message: None,
raw_json_body: None,
raw_http_body: Some(raw_http_body),
sse_id: None,
@@ -431,6 +463,10 @@ impl fmt::Debug for ProtocolWireEvent {
.field("event_type", &self.event_type)
.field("has_json_data", &self.has_json_data)
.field("has_raw_sse_frame", &self.raw_sse_frame.is_some())
.field(
"has_raw_websocket_message",
&self.raw_websocket_message.is_some(),
)
.field("has_raw_json_body", &self.raw_json_body.is_some())
.field("has_raw_http_body", &self.raw_http_body.is_some())
.field("has_sse_id", &self.sse_id.is_some())
@@ -500,7 +536,7 @@ pub struct ProviderResponseTimings {
pub upstream_response_ms: Option<u64>,
/// 上游排除引擎与客户端工具时间后的 API 耗时
pub upstream_api_overhead_ms: Option<f64>,
/// 上游引擎服务总耗时;以下专项指标保留毫秒小数及各自内部计量层级
/// 上游响应级引擎耗时;以下专项指标保留毫秒小数及各自内部计量层级
pub upstream_engine_ms: Option<f64>,
pub upstream_engine_iapi_ttft_ms: Option<f64>,
pub upstream_engine_service_ttft_ms: Option<f64>,
+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);
}
+1 -1
View File
@@ -126,7 +126,7 @@ pub trait ConnectionGuard: Send + 'static {}
pub trait ConnectionLifecycle: Send + Sync {
/// 原子地检查 drain 状态并注册一个活跃连接
///
/// 当本方法成功时,drain 必须等待返回的 guard 被释放;
/// 成功注册不延长关闭流程,活跃连接可随进程退出被中断;
/// 当 drain 已经线性化生效时,本方法必须返回 [`ConnectionDraining`]
fn try_register(&self) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining>;
@@ -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,
@@ -1,45 +1,68 @@
//! 验证响应中断的当前 owner 校验及独立执行间的控制隔离
//! 验证原连接控制端口的透明传递、owner 生命周期与执行隔离
use futures::FutureExt;
use gateway_core::engine::response_control::{ResponseControl, ResponseInterruptError};
use std::sync::{Arc, Mutex};
#[test]
fn interrupt_requires_the_current_response_owner_and_does_not_extend_its_lifetime() {
let control = ResponseControl::default();
assert_eq!(
control.interrupt("resp_a"),
Err(ResponseInterruptError::Unavailable)
);
assert!(control.activate(String::new()).is_none());
let active = control.activate("resp_a".to_owned()).unwrap();
let requested = active.requested();
assert!(control.activate("resp_b".to_owned()).is_none());
assert_eq!(
control.interrupt("resp_b"),
Err(ResponseInterruptError::ResponseMismatch)
);
assert!(active.requested().now_or_never().is_none());
control.interrupt("resp_a").unwrap();
control.interrupt("resp_a").unwrap();
assert_eq!(requested.now_or_never(), Some(()));
let retained_waiter = active.requested();
drop(active);
assert_eq!(
control.interrupt("resp_a"),
Err(ResponseInterruptError::Unavailable)
);
let next = control.activate("resp_b".to_owned()).unwrap();
assert!(next.requested().now_or_never().is_none());
drop(retained_waiter);
use async_trait::async_trait;
use futures::{FutureExt, executor::block_on};
use gateway_core::engine::response_control::{
ResponseControl, ResponseControlTransport, ResponseControlUnavailable,
};
#[derive(Default)]
struct Transport(Mutex<Vec<String>>);
#[async_trait]
impl ResponseControlTransport for Transport {
async fn send(&self, payload: &str) -> Result<(), ResponseControlUnavailable> {
self.0.lock().unwrap().push(payload.to_owned());
Ok(())
}
async fn receive(&self) -> Result<String, ResponseControlUnavailable> {
Ok("upstream reply".to_owned())
}
}
#[test]
fn matching_response_ids_do_not_share_control_between_root_executions() {
let first = ResponseControl::default();
let second = ResponseControl::default();
let active_first = first.activate("resp_same".to_owned()).unwrap();
let active_second = second.activate("resp_same".to_owned()).unwrap();
first.interrupt("resp_same").unwrap();
assert_eq!(active_first.requested().now_or_never(), Some(()));
assert!(active_second.requested().now_or_never().is_none());
fn control_requires_a_live_connection_owner_and_preserves_unknown_payloads() {
block_on(async {
let control = ResponseControl::default();
assert_eq!(
control.send("unknown").await,
Err(ResponseControlUnavailable)
);
assert!(control.receive().now_or_never().is_none());
let transport = Arc::new(Transport::default());
let owner: Arc<dyn ResponseControlTransport> = transport.clone();
control.bind(&owner);
let payload = "{ \"type\": \"future.control\", \"extension\": [1, 2] }";
control.send(payload).await.unwrap();
control.send(payload).await.unwrap();
assert_eq!(*transport.0.lock().unwrap(), [payload, payload]);
assert_eq!(control.receive().await.unwrap(), "upstream reply");
drop(owner);
drop(transport);
assert_eq!(control.send(payload).await, Err(ResponseControlUnavailable));
});
}
#[test]
fn replacing_or_clearing_a_binding_does_not_share_control_between_executions() {
block_on(async {
let first = ResponseControl::default();
let second = ResponseControl::default();
let old: Arc<dyn ResponseControlTransport> = Arc::new(Transport::default());
let current = Arc::new(Transport::default());
let owner: Arc<dyn ResponseControlTransport> = current.clone();
first.bind(&old);
second.bind(&owner);
drop(old);
assert_eq!(first.send("old").await, Err(ResponseControlUnavailable));
second.send("current").await.unwrap();
assert_eq!(*current.0.lock().unwrap(), ["current"]);
second.clear();
assert_eq!(
second.send("cleared").await,
Err(ResponseControlUnavailable)
);
});
}
@@ -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());
}
}
-14
View File
@@ -81,8 +81,6 @@ pub struct HostConfig {
pub logging: LoggingConfig,
#[serde(default)]
pub system_update: SystemUpdateConfig,
#[serde(default = "default_drain_timeout_seconds")]
pub drain_timeout_seconds: u64,
#[serde(default = "default_worker_shutdown_timeout_seconds")]
pub worker_shutdown_timeout_seconds: u64,
}
@@ -110,9 +108,6 @@ impl HostConfig {
if self.listen.port == 0 {
return Err(ConfigError::InvalidField("host.listen.port"));
}
if self.drain_timeout_seconds == 0 {
return Err(ConfigError::InvalidField("host.drain_timeout_seconds"));
}
if self.worker_shutdown_timeout_seconds == 0 {
return Err(ConfigError::InvalidField(
"host.worker_shutdown_timeout_seconds",
@@ -136,11 +131,6 @@ impl HostConfig {
&self.runtime_data_dir
}
#[must_use]
pub const fn drain_timeout(&self) -> Duration {
Duration::from_secs(self.drain_timeout_seconds)
}
#[must_use]
pub const fn worker_shutdown_timeout(&self) -> Duration {
Duration::from_secs(self.worker_shutdown_timeout_seconds)
@@ -264,10 +254,6 @@ fn resolve_relative_path(base: &Path, path: &mut PathBuf) {
}
}
const fn default_drain_timeout_seconds() -> u64 {
30
}
const fn default_worker_shutdown_timeout_seconds() -> u64 {
30
}
+2 -3
View File
@@ -164,7 +164,7 @@ impl HostBundle {
Ok(())
}
/// 进程唯一阻塞点;返回前完成 HTTP drain 与 worker join
/// 进程唯一阻塞点;停止 HTTP 监听后完成 worker join
pub async fn serve(self, router: Router) -> Result<(), HostError> {
let result = serve_router(
router,
@@ -172,7 +172,6 @@ impl HostBundle {
self.config.listen.port,
self.cancellation.clone(),
Arc::clone(&self.connections),
self.config.drain_timeout(),
)
.await;
self.cancellation.cancel();
@@ -181,7 +180,7 @@ impl HostBundle {
.await;
match &result {
Ok(()) => {
tracing::info!(target: "gateway_shutdown", pid = std::process::id(), "HTTP 服务与后台任务已停止")
tracing::info!(target: "gateway_shutdown", pid = std::process::id(), "HTTP 监听已关闭,后台任务已结束")
}
Err(error) => {
tracing::error!(target: "gateway_shutdown", pid = std::process::id(), %error, "HTTP 服务异常停止,后台任务已清理")
+14 -74
View File
@@ -1,13 +1,12 @@
//! HTTP 监听、OS signal、原子连接注册与优雅 drain
//! HTTP 监听、关闭信号与停止时的原子连接注册
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use axum::Router;
use gateway_core::lifecycle::CancellationToken;
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
use tokio::sync::Notify;
const DRAINING_BIT: usize = 1usize << (usize::BITS - 1);
const ACTIVE_MASK: usize = !DRAINING_BIT;
@@ -24,7 +23,6 @@ pub struct ConnectionTracker {
struct ConnectionState {
value: AtomicUsize,
idle: Notify,
}
impl ConnectionTracker {
@@ -33,36 +31,14 @@ impl ConnectionTracker {
Self {
state: Arc::new(ConnectionState {
value: AtomicUsize::new(0),
idle: Notify::new(),
}),
cancellation,
}
}
pub(crate) fn begin_draining(&self) {
let previous = self.state.value.fetch_or(DRAINING_BIT, Ordering::AcqRel);
self.state.value.fetch_or(DRAINING_BIT, Ordering::AcqRel);
self.cancellation.cancel();
if previous & ACTIVE_MASK == 0 {
self.state.idle.notify_waiters();
}
}
pub async fn wait_until_idle(&self, timeout: Duration) {
let wait = async {
loop {
let notified = self.state.idle.notified();
tokio::pin!(notified);
// notify_waiters 不保存许可:必须先 enable 注册等待者,再读
// 活跃计数;否则最后一个 guard 在读数与首次 poll 之间 drop
// 时唤醒丢失,只能等满整个超时
notified.as_mut().enable();
if self.state.value.load(Ordering::Acquire) & ACTIVE_MASK == 0 {
return;
}
notified.await;
}
};
let _ = tokio::time::timeout(timeout, wait).await;
}
}
@@ -106,10 +82,7 @@ impl ConnectionGuard for ActiveConnection {}
impl Drop for ActiveConnection {
fn drop(&mut self) {
let previous = self.state.value.fetch_sub(1, Ordering::AcqRel);
if previous & ACTIVE_MASK == 1 {
self.state.idle.notify_waiters();
}
self.state.value.fetch_sub(1, Ordering::AcqRel);
}
}
@@ -140,59 +113,26 @@ pub(crate) async fn serve_router(
port: u16,
cancellation: CancellationToken,
connections: Arc<ConnectionTracker>,
drain_timeout: Duration,
) -> Result<(), ServeError> {
let listener = bind_listener(&format!("{host}:{port}"))
.await
.map_err(ServeError::Bind)?;
tracing::info!(target: "gateway_startup", pid = std::process::id(), host, port, "网关开始监听");
let shutdown_cancellation = cancellation.clone();
let shutdown_connections = Arc::clone(&connections);
let shutdown = async move {
wait_for_shutdown(&shutdown_cancellation).await;
shutdown_connections.begin_draining();
};
// axum 的优雅关闭会无限等待存量连接结束,而 SSE 等长响应体不观察
// 取消信号:慢客户端可以把关闭拖住直到被 SIGKILL
// 整个 drain(axum
// 优雅关闭 + 游离 guard 等待)共享同一个从取消信号起算的绝对截止点,
// 逾期即放弃等待,存量连接随进程退出终止
let drain_deadline_at = Arc::new(OnceLock::new());
let drain_deadline = {
let cancellation = cancellation.clone();
let deadline_at = Arc::clone(&drain_deadline_at);
async move {
cancellation.cancelled().await;
let deadline = tokio::time::Instant::now() + drain_timeout;
let _ = deadline_at.set(deadline);
tokio::time::sleep_until(deadline).await;
}
};
// 所有关闭入口都停止等待长请求;丢弃 serve 关闭监听,存量连接随进程退出终止
// 后台落盘由 Host 的 Worker 关闭阶段独立收尾
let serve = axum::serve(
listener,
router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.with_graceful_shutdown(shutdown);
tokio::select! {
);
let result = tokio::select! {
biased;
result = serve => {
result.map_err(ServeError::Serve)?;
connections.begin_draining();
let remaining = drain_deadline_at.get().map_or(drain_timeout, |deadline| {
deadline.saturating_duration_since(tokio::time::Instant::now())
});
connections.wait_until_idle(remaining).await;
}
() = drain_deadline => {
tracing::warn!(
target: "gateway_shutdown",
timeout_secs = drain_timeout.as_secs(),
"优雅 drain 超时,放弃等待存量连接"
);
}
}
Ok(())
() = wait_for_shutdown(&cancellation) => Ok(()),
result = serve => result.map_err(ServeError::Serve),
};
connections.begin_draining();
tracing::info!(target: "gateway_shutdown", "停止等待存量连接,后台落盘完成后退出进程");
result
}
pub(crate) async fn wait_for_shutdown(cancellation: &CancellationToken) {
@@ -538,6 +538,7 @@ impl SystemOperations for ProcessSystemOperations {
let cancellation = self.cancellation.clone();
drop(tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(500)).await;
// 留出受理响应返回时间,再走统一关闭流程
cancellation.cancel();
}));
Ok(SystemOperationAccepted::Restart {
+2 -3
View File
@@ -127,9 +127,9 @@ fn host_config_derives_update_paths_from_runtime_data_dir() {
}
#[test]
fn host_config_rejects_zero_drain_window() {
fn host_config_rejects_zero_worker_shutdown_window() {
let mut config = valid_config();
config.drain_timeout_seconds = 0;
config.worker_shutdown_timeout_seconds = 0;
assert!(
config
@@ -223,7 +223,6 @@ fn valid_config() -> HostConfig {
request_dump_retention_days: 1,
},
system_update,
drain_timeout_seconds: 30,
worker_shutdown_timeout_seconds: 30,
}
}
@@ -51,7 +51,6 @@ fn logging_requires_at_least_one_sink() {
request_dump_retention_days: 1,
},
system_update: SystemUpdateConfig::default(),
drain_timeout_seconds: 30,
worker_shutdown_timeout_seconds: 30,
};
@@ -329,7 +328,6 @@ fn logging_config(directory: PathBuf, request_dump: bool) -> HostConfig {
request_dump_retention_days: 1,
},
system_update: SystemUpdateConfig::default(),
drain_timeout_seconds: 30,
worker_shutdown_timeout_seconds: 30,
}
}
+127 -118
View File
@@ -1,4 +1,4 @@
//! 验证宿主服务关闭时的连接排空、超时与并发唤醒
//! 验证统一关闭入口跳过会话等待、保留后台落盘及清理超时
use std::sync::Arc;
use std::sync::Mutex;
@@ -6,17 +6,19 @@ use std::time::Duration;
use axum::{Router, routing::get};
use futures::future::BoxFuture;
use gateway_admin::ports::system::{
SystemOperationError, SystemRestartPreflight, SystemUpdateCandidate,
};
use gateway_core::diagnostics::{OperationalDiagnostics, OperationalFailure};
use gateway_core::lifecycle::CancellationToken;
use gateway_core::lifecycle::ConnectionLifecycle as _;
use gateway_core::task::{
DaemonRestartPolicy, DaemonTask, WorkerContribution, WorkerId, WorkerKind,
WorkerLeaderLeasePort, WorkerLeaseAcquisition, WorkerLeaseError, WorkerLeaseRequest,
WorkerRegistration, WorkerRunnable, WorkerTaskError,
};
use gateway_host::HostBundle;
use gateway_host::serve::{ConnectionTracker, bind_listener};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use gateway_host::serve::bind_listener;
use tokio::io::AsyncWriteExt as _;
use tokio::sync::{Semaphore, mpsc};
use crate::support::host::{configuration, run_in_child};
@@ -28,65 +30,6 @@ fn host_bundle_serve_is_a_consuming_process_entrypoint() {
assert_eq!(std::mem::size_of_val(&_serve), 0);
}
#[tokio::test(start_paused = true)]
async fn wait_until_idle_should_return_immediately_without_active_connections() {
let tracker = ConnectionTracker::new(CancellationToken::new());
let started = tokio::time::Instant::now();
tracker.wait_until_idle(Duration::from_secs(30)).await;
assert_eq!(started.elapsed(), Duration::ZERO);
}
#[tokio::test(start_paused = true)]
async fn wait_until_idle_should_give_up_at_timeout_while_connections_remain() {
let tracker = ConnectionTracker::new(CancellationToken::new());
let _guard = tracker.try_register().expect("register connection");
let started = tokio::time::Instant::now();
tracker.wait_until_idle(Duration::from_secs(30)).await;
assert_eq!(started.elapsed(), Duration::from_secs(30));
}
#[tokio::test]
async fn wait_until_idle_should_wake_when_last_guard_drops_after_first_poll() {
let tracker = ConnectionTracker::new(CancellationToken::new());
let guard = tracker.try_register().expect("register connection");
let mut wait = Box::pin(tracker.wait_until_idle(Duration::from_secs(30)));
assert!(futures::poll!(wait.as_mut()).is_pending());
drop(guard);
tokio::time::timeout(Duration::from_secs(2), wait)
.await
.expect("woken by last guard drop instead of waiting out the timeout");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn wait_until_idle_should_observe_guard_drop_racing_the_idle_check() {
// 回归约束:notified 必须在读取活跃计数前完成注册(enable),否则最后
// 一个 guard 在计数检查与首次 poll 之间 drop 时唤醒丢失,只能等满整个
// 超时
// 多线程反复交错,任何一次丢唤醒都会撞上 2s 超时并触发断言
for _ in 0..256 {
let tracker = Arc::new(ConnectionTracker::new(CancellationToken::new()));
let guard = tracker.try_register().expect("register connection");
let waiter = tokio::spawn({
let tracker = Arc::clone(&tracker);
async move { tracker.wait_until_idle(Duration::from_secs(2)).await }
});
let dropper = std::thread::spawn(move || drop(guard));
let started = std::time::Instant::now();
waiter.await.expect("waiter completes");
dropper.join().expect("dropper completes");
assert!(
started.elapsed() < Duration::from_millis(1_900),
"idle wakeup was lost and the waiter slept until the drain timeout"
);
}
}
#[tokio::test]
async fn bind_listener_should_retry_until_previous_listener_releases_port() {
let holder = tokio::net::TcpListener::bind("127.0.0.1:0")
@@ -110,76 +53,148 @@ async fn bind_listener_should_retry_until_previous_listener_releases_port() {
}
#[test]
fn http_drain_keeps_writer_running_until_the_request_finishes() {
fn cancellation_skips_connections_but_flushes_and_joins_workers() {
run_in_child(
"serve::http_drain_keeps_writer_running_until_the_request_finishes",
async {
let directory = tempfile::tempdir().unwrap();
let (host, address) = listening_host(directory.path()).await;
let cancellation = host.cancellation();
let connections = host.connection_lifecycle();
let probe = start_workers(&host, false);
probe.started.cancelled().await;
let (router, entered, finish_request) = request_router(&host, &probe);
let server = tokio::spawn(host.serve(router));
let mut client = connect_request(address).await;
entered.cancelled().await;
cancellation.cancel();
wait_for_draining(connections.as_ref()).await;
assert!(connections.try_register().is_err());
assert!(
tokio::time::timeout(Duration::from_millis(50), probe.cancelled.cancelled())
.await
.is_err(),
"writer cancelled before the in-flight request finished"
);
finish_request.add_permits(1);
let mut response = String::new();
client.read_to_string(&mut response).await.unwrap();
server.await.unwrap().unwrap();
assert!(response.starts_with("HTTP/1.1 200 OK"));
assert_eq!(*probe.writes.lock().unwrap(), [1]);
assert!(probe.finished.is_cancelled());
},
"serve::cancellation_skips_connections_but_flushes_and_joins_workers",
shutdown_with_pending_request(ShutdownTrigger::Cancellation),
);
}
#[test]
fn drain_timeout_starts_a_separate_worker_shutdown_phase() {
fn self_restart_skips_connections_but_flushes_and_joins_workers() {
run_in_child(
"serve::drain_timeout_starts_a_separate_worker_shutdown_phase",
"serve::self_restart_skips_connections_but_flushes_and_joins_workers",
shutdown_with_pending_request(ShutdownTrigger::SelfRestart),
);
}
#[cfg(unix)]
#[test]
fn sigterm_skips_connections_but_flushes_and_joins_workers() {
run_in_child(
"serve::sigterm_skips_connections_but_flushes_and_joins_workers",
shutdown_with_pending_request(ShutdownTrigger::Signal("-TERM")),
);
}
#[cfg(unix)]
#[test]
fn sigint_skips_connections_but_flushes_and_joins_workers() {
run_in_child(
"serve::sigint_skips_connections_but_flushes_and_joins_workers",
shutdown_with_pending_request(ShutdownTrigger::Signal("-INT")),
);
}
enum ShutdownTrigger {
Cancellation,
SelfRestart,
#[cfg(unix)]
Signal(&'static str),
}
async fn shutdown_with_pending_request(trigger: ShutdownTrigger) {
let directory = tempfile::tempdir().unwrap();
let reservation = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = reservation.local_addr().unwrap();
let mut config = configuration(directory.path());
config.listen.port = address.port();
config.system_update.deployment_mode = "docker".to_owned();
config.system_update.build_type = "source".to_owned();
config.system_update.self_restart_enabled = true;
config.system_update.update_lock_file = directory.path().join("update.lock");
let host = gateway_host::initialize(config).await.unwrap();
drop(reservation);
let system = host.system_operations();
let cancellation = host.cancellation();
let connections = host.connection_lifecycle();
let guard = connections.try_register().unwrap();
let probe = start_workers(&host, true);
probe.started.cancelled().await;
let (router, entered, _finish_request) = request_router(&host, &probe);
let server = tokio::spawn(host.serve(router));
let client = connect_request(address).await;
entered.cancelled().await;
probe.sender.send(7).unwrap();
match trigger {
ShutdownTrigger::Cancellation => cancellation.cancel(),
ShutdownTrigger::SelfRestart => {
system.restart(Arc::new(AllowRestart)).await.unwrap();
}
#[cfg(unix)]
ShutdownTrigger::Signal(signal) => {
// 真实请求进入后信号监听已被 poll,只向隔离的当前测试子进程发送信号
assert!(
std::process::Command::new("kill")
.args([signal, &std::process::id().to_string()])
.status()
.unwrap()
.success()
);
}
}
tokio::time::timeout(Duration::from_secs(2), probe.cancelled.cancelled())
.await
.expect("shutdown must not wait for the blocked HTTP request or live guard");
assert!(connections.is_draining());
assert!(connections.try_register().is_err());
drop(guard);
assert!(connections.try_register().is_err());
assert!(
tokio::net::TcpStream::connect(address).await.is_err(),
"listener must close before worker cleanup finishes"
);
assert!(
!server.is_finished(),
"worker cleanup still needs to finish"
);
probe.finish_shutdown.add_permits(1);
server.await.unwrap().unwrap();
assert_eq!(*probe.writes.lock().unwrap(), [7]);
assert!(probe.finished.is_cancelled());
// 请求仍未结束,独立进程退出时终止连接和挂起 handler
drop(client);
}
#[test]
fn shutdown_aborts_workers_that_exceed_the_cleanup_budget() {
run_in_child(
"serve::shutdown_aborts_workers_that_exceed_the_cleanup_budget",
async {
let directory = tempfile::tempdir().unwrap();
let (host, address) = listening_host(directory.path()).await;
let cancellation = host.cancellation();
let connections = host.connection_lifecycle();
let probe = start_workers(&host, true);
probe.started.cancelled().await;
let (router, entered, finish_request) = request_router(&host, &probe);
let (router, entered, _finish_request) = request_router(&host, &probe);
let server = tokio::spawn(host.serve(router));
let client = connect_request(address).await;
entered.cancelled().await;
let started = tokio::time::Instant::now();
cancellation.cancel();
wait_for_draining(connections.as_ref()).await;
probe.cancelled.cancelled().await;
assert!(started.elapsed() >= Duration::from_secs(1));
assert!(!server.is_finished(), "worker cleanup was not joined");
assert!(!probe.finished.is_cancelled());
probe.finish_shutdown.add_permits(1);
server.await.unwrap().unwrap();
assert!(probe.finished.is_cancelled());
// 超时后真实连接可以仍在 axum 任务中;释放测试端,避免遗留挂起 handler
tokio::time::timeout(Duration::from_secs(3), server)
.await
.expect("worker cleanup budget must bound process shutdown")
.unwrap()
.unwrap();
probe.dropped.cancelled().await;
assert!(probe.cancelled.is_cancelled());
assert!(!probe.finished.is_cancelled());
drop(client);
finish_request.add_permits(1);
},
);
}
struct AllowRestart;
#[async_trait::async_trait]
impl SystemRestartPreflight for AllowRestart {
async fn prepare(&self, _: Option<SystemUpdateCandidate>) -> Result<(), SystemOperationError> {
Ok(())
}
}
#[test]
fn bind_failure_cancels_and_joins_workers_before_returning() {
run_in_child(
@@ -291,12 +306,6 @@ async fn connect_request(address: std::net::SocketAddr) -> tokio::net::TcpStream
client
}
async fn wait_for_draining(connections: &dyn gateway_core::lifecycle::ConnectionLifecycle) {
while !connections.is_draining() {
tokio::task::yield_now().await;
}
}
fn request_router(
host: &HostBundle,
probe: &WriterProbe,
@@ -319,7 +328,7 @@ fn request_router(
let _connection = connections.try_register().unwrap();
entered.cancel();
finish.acquire().await.unwrap().forget();
// 对应请求结束后写入终态的时机;drain 超时后的写入允许被拒绝
// 对应请求结束后写入终态的时机;关闭开始后的写入允许被拒绝
if sender.send(1).is_ok() {
"recorded"
} else {
@@ -59,7 +59,6 @@ pub fn configuration(directory: &Path) -> HostConfig {
request_dump_retention_days: 1,
},
system_update: Default::default(),
drain_timeout_seconds: 1,
worker_shutdown_timeout_seconds: 1,
}
}
@@ -1,20 +1 @@
[
{
"capability": "middleware",
"version": 3,
"replacement_version": 4,
"introduced_in": "3.20.0",
"last_counted_in": "3.22.0",
"remaining_releases": 2,
"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.0",
"remaining_releases": 2,
"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)?,
@@ -467,7 +459,11 @@ impl MiddlewareBody for PluginMiddlewareBody {
&& frame
.event()
.is_some_and(|event| event.wire_event().is_none());
if !facts_only && !valid_plugin_frame(self.framing, frame.bytes()) {
// 来源句柄已核对正文与交付归属,未改写的原始内容不依赖 JSON 解析
if frame.transformed()
&& !facts_only
&& !valid_plugin_frame(self.framing, frame.bytes())
{
return Err(invalid());
}
if self.pending_transformed {
@@ -447,6 +447,9 @@ async fn dispatch_websocket_queue(
}
fn websocket_payload(observation: &WebSocketResponseObservation) -> Result<Vec<u8>, ()> {
if let Some(raw) = observation.wire().raw_websocket_message() {
return Ok(raw.as_bytes().to_vec());
}
if let Some(raw) = observation.wire().raw_json_body() {
return Ok(raw.to_vec());
}
@@ -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;
@@ -287,6 +287,15 @@ fn project_wire(wire: &ProtocolWireEvent) -> Result<WireEvent, PluginFault> {
WirePayload::RawSse {
frame: frame.to_vec(),
}
} else if let Some(message) = wire.raw_websocket_message() {
// 子模型回调采用 SDK 的事件流表达,不把 WebSocket 文本冒充 HTTP body
WirePayload::RawSse {
frame: gateway_protocol::openai::sse::encode_sse_event(
wire.event_type().unwrap_or_default(),
message,
)
.into_bytes(),
}
} else {
return Err(PluginFault::new(
ErrorCode::Fault,
@@ -212,10 +212,11 @@ impl BodyResource {
};
let transformed = frame.transformed();
let (bytes, framing, terminal, envelope) = frame.into_parts();
// canonical-only 事件没有客户端正文,仍须保留并允许读取其完整事实
let facts_only =
framing == MiddlewareFraming::RawBytes && bytes.is_empty() && envelope.is_some();
if (!facts_only && framing != self.expected_framing) || bytes.len() > maximum {
// 宿主原始字节可能没有 JSON 解析视图,包含未知 WS 文本和 canonical-only 事实
// 预期 framing 约束插件改写后的输出,不能阻止插件读取并原样归还来源
if (framing != MiddlewareFraming::RawBytes && framing != self.expected_framing)
|| bytes.len() > maximum
{
return Err(invalid());
}
let source_id = state.next_source_id.checked_add(1).ok_or_else(invalid)?;
@@ -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
@@ -21,6 +21,92 @@ use gateway_core::{
upstream::{OpaqueUpstreamValue, UpstreamTransport},
};
#[tokio::test]
async fn reading_websocket_facts_preserves_original_text_and_opaque_messages() {
let records = tempfile::tempdir().unwrap();
let marker = records.path().join("facts.jsonl");
let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap();
let package = crate::support::package_with_contributions(
&worker,
Contributions::from([crate::support::contribution(
Capability::Middleware,
vec![Stage::Attempt],
vec!["openai".into()],
vec!["openai".into()],
)]),
);
let (_cache, runtime) = setup_package(vec![InstanceFixture {
id: "facts-reader",
configuration: serde_json::json!({"mode":"facts", "attempt":true, "facts_marker":marker}),
bindings: vec![binding(MIDDLEWARE_CONTRIBUTION, "attempt", 0, PluginFailurePolicy::Reject)],
}], package).await;
let generation = prepare(&runtime).await;
let plan = runtime
.execution_registry()
.middleware(&generation)
.unwrap();
let messages = [
"{ \"type\" : \"future.event\", \"number\" : 1e3, \"text\" : \"\\u0061\" }".to_owned(),
format!(
"{{\"type\":\"future.deep\",\"value\":{}0{}}}",
"[".repeat(140),
"]".repeat(140)
),
"future non-JSON text".to_owned(),
];
let events: Vec<_> = messages
.iter()
.map(|raw| {
let wire = match serde_json::from_str::<serde_json::Value>(raw) {
Ok(data) => ProtocolWireEvent::json("openai", None, data)
.unwrap()
.with_raw_websocket_message(raw.as_str()),
Err(_) => ProtocolWireEvent::raw_websocket("openai", raw.as_str()).unwrap(),
};
Ok(ProviderEvent::wire(wire))
})
.collect();
let mut stream = execute_attempt_middleware(
Some(&plan),
attempt_middleware_context_for_transport(Arc::default(), ClientTransport::WebSocket),
operation(),
ClientTransport::WebSocket,
Box::new(move |_, _| {
Box::pin(async move {
Ok(ProviderStream::new(
ProviderCallMetadata::new(
ProviderKind::new("openai").unwrap(),
UpstreamModelId::new("native-model").unwrap(),
ProviderAccountId::new("acct_original").unwrap(),
UpstreamTransport::new("websocket").unwrap(),
),
futures::stream::iter(events),
(),
))
})
}),
)
.await
.unwrap();
let mut delivered = Vec::new();
while let Some(event) = stream.next().await {
let event = event.unwrap();
assert!(!event.middleware_transformed());
delivered.push(
event
.wire_event()
.unwrap()
.raw_websocket_message()
.unwrap()
.to_owned(),
);
}
assert_eq!(delivered, messages);
assert_eq!(marker_lines(&marker, 4).await.len(), 4);
drop(stream);
runtime.shutdown().await;
}
#[tokio::test]
async fn facts_are_readable_without_replacing_or_duplicating_the_original_execution() {
let records = tempfile::tempdir().unwrap();
@@ -705,6 +705,13 @@ fn middleware_context(transport: ClientTransport) -> MiddlewareContext {
}
fn attempt_middleware_context(effects: Arc<ExecutionEffects>) -> MiddlewareContext {
attempt_middleware_context_for_transport(effects, ClientTransport::HttpJson)
}
fn attempt_middleware_context_for_transport(
effects: Arc<ExecutionEffects>,
transport: ClientTransport,
) -> MiddlewareContext {
MiddlewareContext::new(
MiddlewareTarget {
request_id: ModelRequestId::new("req_attempt_http").unwrap(),
@@ -712,7 +719,7 @@ fn attempt_middleware_context(effects: Arc<ExecutionEffects>) -> MiddlewareConte
attempt_index: Some(NonZeroU32::MIN),
operation: Some(OperationKind::Generate),
endpoint: "/v1/responses".into(),
transport: ClientTransport::HttpJson,
transport,
provider: Some(ProviderKind::new("openai").unwrap()),
model: Some("public-a".into()),
account_id: Some(ProviderAccountId::new("acct_openai").unwrap()),
@@ -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(),
@@ -1,5 +1,7 @@
//! PostgreSQL 迁移屏障、连接池与会话级资源预算
use std::time::Duration;
use sqlx::{
PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
@@ -37,11 +39,34 @@ pub async fn connect_and_migrate(
)
.await
.map_err(|source| postgres_unavailable("connect PostgreSQL for migrations", source))?;
if let Err(error) = MIGRATOR.run(&migration_pool).await {
migration_pool.close().await;
let started = tokio::time::Instant::now();
tracing::info!(target: "gateway_startup", "开始检查并执行数据库迁移");
let result = {
let migration = MIGRATOR.run(&migration_pool);
tokio::pin!(migration);
let progress_interval = Duration::from_secs(15);
let mut progress = tokio::time::interval_at(started + progress_interval, progress_interval);
progress.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
biased;
result = &mut migration => break result,
_ = progress.tick() => {
// 迁移或迁移锁等待都不能被进度提示中断,不将已等待时间冒充完成比例
tracing::info!(target: "gateway_startup", elapsed_seconds = started.elapsed().as_secs(),
"数据库迁移仍在执行或等待锁,请等待完成,避免重复重启");
}
}
}
};
migration_pool.close().await;
if let Err(error) = result {
tracing::error!(target: "gateway_startup", elapsed_seconds = started.elapsed().as_secs_f64(),
"数据库迁移失败");
return Err(postgres_unavailable("apply PostgreSQL migrations", error));
}
migration_pool.close().await;
tracing::info!(target: "gateway_startup", elapsed_seconds = started.elapsed().as_secs_f64(),
"数据库迁移检查完成");
connect_pool(connect_options, pool_config, false).await
}
@@ -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?
}
@@ -229,7 +255,7 @@ impl CodexProvider {
.map_err(map_selection_error)?;
}
let lease = Arc::new(lease);
match operation {
let passthrough_headers = match operation {
Operation::GenerateImage(image) => {
let Operation::GenerateImage(original) = &request.operation else {
return Err(provider_error(
@@ -250,6 +276,7 @@ impl CodexProvider {
.get("image_turn_id")
.and_then(Value::as_str)
.map(str::to_owned);
crate::transport::request::decode_passthrough_headers(image.payload().context())
}
Operation::Search(search) => {
if !matches!(&request.operation, Operation::Search(_))
@@ -267,6 +294,7 @@ impl CodexProvider {
.get("turn_metadata")
.and_then(Value::as_str)
.map(str::to_owned);
crate::transport::request::decode_passthrough_headers(search.payload().context())
}
_ => {
return Err(provider_error(
@@ -274,7 +302,7 @@ impl CodexProvider {
UpstreamSendState::NotSent,
));
}
}
};
if !context.is_diagnostic_required_account()
&& let Some(affinity) = affinity.as_ref()
&& let Some(turn) = affinity.turn_alias()
@@ -315,10 +343,12 @@ 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,
passthrough_headers,
image_turn_id: request.image_turn_id,
turn_metadata,
context,
@@ -372,6 +402,7 @@ pub(super) struct ColdJsonResponse {
pub(super) response_origin: Url,
pub(super) endpoint_path: &'static str,
pub(super) body: Bytes,
pub(super) passthrough_headers: reqwest::header::HeaderMap,
pub(super) image_turn_id: Option<String>,
pub(super) turn_metadata: Option<String>,
pub(super) context: AttemptContext,
@@ -572,6 +603,7 @@ pub(super) async fn create_json_attempt(
response = request.client.post_raw_json(
request.endpoint_path,
request.body.clone(),
&request.passthrough_headers,
request.image_turn_id.as_deref(),
request_context,
) => response.map_err(CodexHandshakeAttemptError::Client),
@@ -910,10 +942,8 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
OpenAiPassiveQuotaObservation::new(response.rate_limit_headers);
let rate_limit_updates = response.rate_limit_updates;
let response_metadata_updates = response.response_metadata_updates;
// OpenAI 线路为透明代理:HTTP SSE 与 WebSocket 两条上游均启用 raw 透传,
// 下游按字节转发上游原文,避免 serde 往返改写数值/精度(大整数→f64、logprobs 等)
// WS 帧由 reducer 以 encode_sse_event(&event, raw) 逐字节内嵌上游原始 JSON
// (transport/protocol/websocket.rs),push_frames 抽出的 data 即上游原文
// 同协议保留原始帧;canonical 解析只提取计费、续接和状态事实
let websocket_transport = response.transport == CodexBackendTransport::WebSocket;
let mut decoder = CodexCanonicalDecoder::new(upstream_model.as_str())
.with_pricing(context.pricing().get("openai").and_then(|models| models.get(upstream_model.as_str())).cloned())
.with_reported_model(response.response_metadata.effective_model.as_deref())
@@ -1046,7 +1076,13 @@ pub(super) fn cold_response_stream(response: ColdResponse) -> EventStream {
let first_event_changed =
observation_state.observe_stream_chunk(&chunk, context.timing_started_at());
let chunk_len = chunk.len();
let (mut events, canonical_failure) = match decoder.push(&chunk) {
let decoded = if websocket_transport {
let text = std::str::from_utf8(&chunk).map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::Sent))?;
decoder.push_websocket(text)
} else {
decoder.push(&chunk)
};
let (mut events, canonical_failure) = match decoded {
CodexCanonicalOutcome::Events(events) => (events, None),
CodexCanonicalOutcome::Failed(failure) => {
let (events, error, semantic_output_seen) = failure.into_parts();
@@ -84,10 +84,8 @@ pub(super) async fn wait_for_replay_grace(deadline: Option<Instant>) {
/// 提交边界前的上游事件预取
///
/// 原始 chunk 计数而不是重编码后的 event 大小
/// 时间与字节阈值共同限定无感换号
/// 窗口;任一边界到达都会提交已缓存 wire,不能因网关私有资源规则伪造上游协议
/// 失败
/// 结构事件保留到宽限期结束、语义输出、终态或 EOF,再提交已缓存 wire
/// 大段配置回显不应提前结束无感换号窗口,原始 chunk 字节数只用于诊断
/// 一旦提交,后续事件不再具备无痕重放资格
pub(super) struct PreCommitClientEvents {
pending: Vec<ProviderEvent>,
@@ -102,7 +100,6 @@ pub(super) struct PreCommitClientEvents {
pub(super) enum PreCommitReleaseReason {
SemanticOutput,
Terminal,
ByteLimit,
GraceTimeout,
Eof,
}
@@ -141,9 +138,6 @@ impl PreCommitClientEvents {
if completed {
return self.commit_pending(PreCommitReleaseReason::Terminal);
}
if self.prefetched_bytes > MAX_STREAM_PREFETCH_BYTES {
return self.commit_pending(PreCommitReleaseReason::ByteLimit);
}
if starts_replay_grace && self.replay_grace_started_at.is_none() {
self.replay_grace_started_at = Some(Instant::now());
}
@@ -896,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),
@@ -1121,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,
@@ -125,10 +125,6 @@ const WEBSOCKET_TRANSPORT: &str = "websocket";
// 在已观测到的 Codex OAuth 上游 16 MiB 附近消息边界前留出传输 metadata 余量
const WEBSOCKET_HTTP_FALLBACK_THRESHOLD_BYTES: usize = 15 * 1024 * 1024;
const MAX_COOKIE_HEADER_BYTES: usize = 16 * 1024;
/// 提交边界前预取 128 KiB 原始上游 chunk;容纳携带配置回显的前导事件,
/// 超过阈值后结束无感换号窗口(最后一个 chunk 可越过阈值),
/// 但不会把上游数据改写成协议失败
const MAX_STREAM_PREFETCH_BYTES: usize = 128 * 1024;
/// 短暂保留 response.created 等结构事件,让随后到达的明确拒绝可以无感换号;
/// 到期即放行,避免模型长时间思考时让客户端一直收不到首事件
const STREAM_REPLAY_GRACE: Duration = Duration::from_millis(2_500);
@@ -380,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,
@@ -752,7 +758,6 @@ impl CodexProvider {
UpstreamSendState::NotSent,
));
}
validate_openai_reasoning(generate.protocol_payload().body())?;
let mut upstream = encode_generate_request(&generate, upstream_model.as_str(), None)
.map_err(map_request_error)?;
upstream.client_account_follow_only = crate::request_identity::follows_session_with_headers(
@@ -1021,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,
@@ -1057,26 +1063,3 @@ fn native_request_requirements(request: &GenerateRequest) -> CapabilityRequireme
))
.capability_requirements()
}
fn validate_openai_reasoning(body: &Map<String, Value>) -> Result<(), ProviderError> {
let Some(effort) = body
.get("reasoning")
.and_then(Value::as_object)
.and_then(|reasoning| reasoning.get("effort"))
else {
return Ok(());
};
let Some(effort) = effort.as_str() else {
return Err(provider_error(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
));
};
if effort.is_empty() || effort.len() > 64 || effort.chars().any(char::is_control) {
return Err(provider_error(
ProviderErrorKind::InvalidRequest,
UpstreamSendState::NotSent,
));
}
Ok(())
}

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