mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-09 16:06:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
389dc63633 | ||
|
|
78b2250faa | ||
|
|
bbb8278281 | ||
|
|
7a5521953f | ||
|
|
f78b1ca487 | ||
|
|
661649cc90 | ||
|
|
705f0fad06 | ||
|
|
f0c60f501b | ||
|
|
d1febdd249 | ||
|
|
53d4086cc0 | ||
|
|
ce03e8119d | ||
|
|
3f05b6450c | ||
|
|
a4b73099b5 | ||
|
|
d875a62652 | ||
|
|
98704dfde8 | ||
|
|
b07bca312a |
@@ -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` 管理握手、回调关联、流控、取消和关闭
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+1
@@ -3289,6 +3289,7 @@ dependencies = [
|
||||
"native-tls",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"roxmltree",
|
||||
"rustls",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 服务异常停止,后台任务已清理")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
+2
-6
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user