Compare commits

...
7 Commits
Author SHA1 Message Date
ZyyandCodex 6a5300a7b5 chore(release): v3.32.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 23:34:39 +08:00
2c5b8b5c1a feat(openai): configure residency and account routing headers (#515)
Accept optional deployment strings for residency and account routing,
discard downstream values when unset, and isolate WebSocket connections
by the configured handshake headers.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 23:19:11 +08:00
0a87ac7f76 fix(plugins)!: honor declared stage call budgets (#513)
Resolve scenario defaults and plugin declarations consistently across the SDK
and runtime. Preserve maintenance across unrelated publications and cancel
long preparation when the runtime shuts down.

BREAKING CHANGE: Plugin RPC requires protocol v6. Rebuild plugins with the
updated SDK; previous protocol versions are not supported.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 22:39:23 +08:00
ZyyandCodex 3ac9d5f6f8 ci: keep repository checks independent
Remove automatic cross-repository source integration from host CI while
preserving host checks against locked dependencies. Keep source integration
available for explicit local version combinations and align the documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 22:34:48 +08:00
ZyyandCodex e7e5b659cd chore(release): v3.31.3
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 17:31:19 +08:00
ZyyandCodex b225b3732d fix(accounts): unify Codex labels and tighten plan badge spacing
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 17:28:06 +08:00
ZyyandCodex 9dca763ea4 fix(ui): polish plugin management and API key labels
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 16:57:42 +08:00
128 changed files with 2013 additions and 729 deletions
+1 -66
View File
@@ -29,7 +29,6 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
container: ${{ steps.filter.outputs.container }}
source: ${{ steps.filter.outputs.source }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -41,7 +40,7 @@ jobs:
id: filter
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
# 目录规则取并集,再统一排除说明文档,保留隐藏配置与子模块指针
# 目录规则取并集,再统一排除说明文档,保留隐藏配置
predicate-quantifier: some-with-excludes
filters: |
backend:
@@ -61,17 +60,6 @@ jobs:
- '.github/workflows/ci.yml'
- '.github/workflows/_quality.yml'
- '!**/*.md'
source:
- '.github/scripts/toolchains.rb'
- 'backend/rust-toolchain.toml'
- 'deploy/Dockerfile'
- '.gitmodules'
- 'modules/ui'
- 'modules/plugins'
- 'frontend/**'
- 'backend/crates/gateway-plugin/sdk/**'
- '.github/workflows/ci.yml'
- '!**/*.md'
container:
- 'backend/**'
- 'frontend/**'
@@ -101,56 +89,3 @@ jobs:
uses: ./.github/workflows/_container.yml
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
source-development:
needs: changes
if: needs.changes.outputs.source == 'true'
runs-on: ubuntu-26.04
timeout-minutes: 20
steps:
- name: Checkout with development modules
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
submodules: recursive
- name: Setup pnpm
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
package_json_file: frontend/package.json
- name: Read build toolchains
id: toolchains
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ steps.toolchains.outputs.node }}
- name: Setup Rust
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
with:
toolchain: ${{ steps.toolchains.outputs.rust }}
- name: Install development dependencies
run: |
pnpm --dir modules/ui install --frozen-lockfile
pnpm --dir frontend install --frozen-lockfile
pnpm --dir modules/plugins/examples/workbench/frontend install --frozen-lockfile
- name: Check UI source integration
run: |
pnpm --dir frontend build:source
pnpm --dir modules/plugins/examples/workbench/frontend build:source
- name: Check local SDK integration
env:
RUST_MIN_STACK: 16777216
CARGO_TARGET_DIR: ${{ github.workspace }}/backend/target/plugin-development
run: |
sdk_check_dir="$RUNNER_TEMP/plugin-source"
mkdir -p "$sdk_check_dir/backend"
cp modules/plugins/examples/workbench/backend/Cargo.{toml,lock} "$sdk_check_dir/backend/"
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$sdk_check_dir/backend/"
cp modules/plugins/examples/workbench/plugin.json "$sdk_check_dir/"
cargo test --manifest-path "$sdk_check_dir/backend/Cargo.toml" \
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$GITHUB_WORKSPACE/backend/crates/gateway-plugin/sdk\""
- name: Ensure source checks preserve lockfiles
run: |
git diff --exit-code -- frontend/package.json frontend/pnpm-lock.yaml backend/Cargo.lock
git -C modules/plugins diff --exit-code -- examples/workbench/frontend/package.json examples/workbench/frontend/pnpm-lock.yaml examples/workbench/backend/Cargo.lock
+1 -1
View File
@@ -228,7 +228,7 @@ git diff --check
## 自动检查与合并条件
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端、容器和源码联调任务,工作流语法检查独立执行
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端和容器任务,工作流语法检查独立执行
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
+21 -3
View File
@@ -370,10 +370,28 @@ fn config_loader_should_reject_zero_server_port() {
}
#[test]
fn config_loader_should_validate_openai_residency() {
fn config_loader_should_accept_optional_openai_routing_strings() {
for value in [
serde_json::Value::Null,
serde_json::json!("us"),
serde_json::json!("custom-value"),
serde_json::json!(""),
] {
let mut document = valid_config_document();
document["openai"]["residency"] = value.clone();
document["openai"]["account_routing_override"] = value;
parse_config(&document.to_string()).expect("optional routing strings");
}
let mut document = valid_config_document();
document["openai"]["residency"] = serde_json::json!("invalid");
assert_rejected(document.to_string());
document["openai"]
.as_object_mut()
.unwrap()
.remove("residency");
document["openai"]
.as_object_mut()
.unwrap()
.remove("account_routing_override");
parse_config(&document.to_string()).expect("omitted routing strings");
}
fn assert_rejected(config: String) {
@@ -1,7 +1,7 @@
{
"schema_version": 2,
"manifest_schema_versions": [2],
"protocol_versions": [5],
"protocol_versions": [6],
"capabilities": [
{ "capability": "frontend_authentication", "versions": [2] },
{ "capability": "scheduler", "versions": [3] },
@@ -1,7 +1,5 @@
//! 编译插件模型目录贡献并校验能力绑定
use std::time::Duration;
use gateway_admin::model::{
AdminError,
plugins::instances::{PluginCapabilityBinding, PluginInstance},
@@ -23,7 +21,7 @@ pub(crate) async fn prepare(
let reply = session
.call(
"model_catalog.register",
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
Vec::new(),
)
@@ -48,12 +48,13 @@ impl PluginCommandSession {
let payload = serde_json::to_vec(&invocation)
.map_err(|_| AdminError::invalid("插件命令参数无法编码"))?;
let mut saved_accounts = 0;
let deadline = tokio::time::Instant::now() + command.session.budget(Stage::CommandLine);
let result = tokio::select! {
biased;
() = cancellation.cancelled() => Ok(Err("调用已取消")),
result = tokio::time::timeout(
self.limits.maximum_call_timeout,
command.execute(payload, self.limits, &mut saved_accounts),
result = tokio::time::timeout_at(
deadline,
command.execute(payload, self.limits, deadline, &mut saved_accounts),
) => result,
};
match result {
@@ -76,11 +77,14 @@ impl PluginCommand {
&self,
payload: Vec<u8>,
limits: RpcLimits,
deadline: tokio::time::Instant,
saved_accounts: &mut usize,
) -> Result<PluginCommandOutput, &'static str> {
let mut context = self
.session
.context(Stage::CommandLine, limits.maximum_call_timeout);
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err("超过命令总期限");
}
let mut context = self.session.context(Stage::CommandLine, remaining);
context.request_id = Some(uuid::Uuid::new_v4().to_string());
let scope = self
.callbacks
@@ -135,6 +139,7 @@ fn rpc_failure(error: RpcError) -> &'static str {
}
RpcError::Capacity | RpcError::Overloaded => "插件调用容量不足",
RpcError::Context(_) => "调用上下文无效",
RpcError::Budget(_) => "调用预算冲突",
RpcError::Remote(_) => "插件拒绝命令",
}
}
@@ -4,7 +4,7 @@ use super::PluginCommand;
use crate::{RpcSession, callback::PluginCallbacks};
use gateway_admin::model::{AdminError, plugins::instances::PluginInstance};
use gateway_plugin_sdk::{Capability, Manifest, Stage, call::management::CommandRegistration};
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
const MAXIMUM_REGISTRATION_BYTES: usize = 64 * 1024;
@@ -23,7 +23,7 @@ pub(crate) async fn prepare(
let reply = session
.call(
"command_line.register",
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
Vec::new(),
)
@@ -1,6 +1,6 @@
//! 编译插件客户端认证绑定,并将认证结果映射为宿主身份
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use std::{collections::BTreeMap, sync::Arc};
use futures::future::BoxFuture;
use gateway_admin::model::{
@@ -26,8 +26,6 @@ use gateway_plugin_sdk::{
use crate::{RpcSession, callback::PluginCallbacks};
const IDENTIFIER_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone)]
pub(crate) struct FrontendAuthenticationEntry {
identities: BTreeMap<String, ClientApiKeyId>,
@@ -47,7 +45,7 @@ pub(crate) async fn prepare_entry(
let reply = session
.call(
"frontend_auth.identifier",
session.context(Stage::Registration, IDENTIFIER_TIMEOUT),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
Vec::new(),
)
@@ -167,19 +165,16 @@ fn valid_identifier(value: &str) -> bool {
pub(crate) struct PluginFrontendAuthenticationPlan {
entry: FrontendAuthenticationEntry,
timeout: Duration,
}
impl PluginFrontendAuthenticationPlan {
pub(crate) fn compile(
mut entries: Vec<FrontendAuthenticationEntry>,
maximum_call_timeout: Duration,
) -> Result<Option<Arc<Self>>, AdminError> {
match entries.len() {
0 => Ok(None),
1 => Ok(Some(Arc::new(Self {
entry: entries.pop().expect("one entry was checked"),
timeout: maximum_call_timeout.min(Duration::from_secs(30)),
}))),
_ => Err(AdminError::invalid("同一发布集合只能启用一个入口认证插件")),
}
@@ -197,7 +192,8 @@ impl FrontendAuthenticationPlan for PluginFrontendAuthenticationPlan {
.invocation
.as_ref()
.ok_or(FrontendAuthenticationError)?;
let mut context = session.context(Stage::Authentication, self.timeout);
let mut context =
session.context(Stage::Authentication, session.budget(Stage::Authentication));
context.request = crate::adapter::request_context(request.request_context());
let _scope = callbacks
.prepare_frontend_authentication(&context, request.shared_request_context())
@@ -165,9 +165,10 @@ impl ManagementEntry {
{
return Err(invalid_state());
}
let mut context = self
.session
.context(Stage::PublicManagement, limits.maximum_call_timeout);
let mut context = self.session.context(
Stage::PublicManagement,
self.session.budget(Stage::PublicManagement),
);
context.request = crate::adapter::request_context(request.request_context.as_deref());
context.request_id = Some(request.request_id);
let _scope = self
@@ -98,7 +98,7 @@ impl ManagementEntry {
.map_err(|_| AdminError::invalid("插件管理请求无法编码"))?;
let mut context = self
.session
.context(Stage::Management, limits.maximum_call_timeout);
.context(Stage::Management, self.session.budget(Stage::Management));
context.request = crate::adapter::request_context(request.request_context.as_deref());
context.request_id = Some(request.request_id);
let _scope = self
@@ -1,6 +1,6 @@
//! 将插件管理声明编译为页面、路由与资源入口
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use std::{collections::BTreeMap, sync::Arc};
use gateway_admin::model::{
AdminError,
@@ -34,7 +34,7 @@ pub(crate) async fn prepare(
let reply = session
.call(
"management.register",
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
Vec::new(),
)
@@ -7,7 +7,7 @@ use gateway_core::engine::middleware::{
MiddlewareRequest, MiddlewareResponse,
};
use gateway_core::middleware::{Middleware, Next};
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
// 协议适配只提供选择条件与单层调用;顺序、上下文捕获和续体拼接共用这一个入口
type Invoke<Context, Input, Output, Error> = fn(
@@ -15,7 +15,6 @@ type Invoke<Context, Input, Output, Error> = fn(
Context,
Input,
Next<Input, Output, Error>,
Duration,
) -> BoxFuture<'static, Result<Output, Error>>;
impl PluginMiddlewarePlan {
@@ -42,8 +41,7 @@ impl PluginMiddlewarePlan {
.map(|entry| -> Middleware<Input, Output, Error> {
let entry = Arc::clone(entry);
let context = context.clone();
let timeout = self.middleware_timeout;
Box::new(move |input, next| invoke(entry, context, input, next, timeout))
Box::new(move |input, next| invoke(entry, context, input, next))
})
.collect();
gateway_core::middleware::compose(layers, move |input| next.run(input))
@@ -70,10 +68,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
MiddlewareMount::Outbound,
context,
outbound_matches,
|entry, context, request, next, timeout| {
Box::pin(super::outbound::invoke(
entry, context, request, next, timeout,
))
|entry, context, request, next| {
Box::pin(super::outbound::invoke(entry, context, request, next))
},
next,
)
@@ -99,8 +95,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
MiddlewareMount::Service,
context,
|entry, context| !context.extensions.contains(&entry.instance_id),
|entry, context, input, next, timeout| {
Box::pin(super::service::invoke(entry, context, input, next, timeout))
|entry, context, input, next| {
Box::pin(super::service::invoke(entry, context, input, next))
},
next,
)
@@ -123,10 +119,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
MiddlewareMount::WebSocket,
context,
|_entry, _context| true,
|entry, context, message, next, timeout| {
Box::pin(super::websocket::invoke(
entry, context, message, next, timeout,
))
|entry, context, message, next| {
Box::pin(super::websocket::invoke(entry, context, message, next))
},
next,
)
@@ -147,8 +141,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
MiddlewareMount::Http,
context,
|entry, context| !context.extensions.contains(&entry.instance_id),
|entry, context, request, next, timeout| {
Box::pin(super::http::invoke(entry, context, request, next, timeout))
|entry, context, request, next| {
Box::pin(super::http::invoke(entry, context, request, next))
},
next,
)
@@ -173,10 +167,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
context.model(),
)
},
|entry, context, request, next, timeout| {
Box::pin(super::request::invoke(
entry, context, request, next, timeout,
))
|entry, context, request, next| {
Box::pin(super::request::invoke(entry, context, request, next))
},
next,
)
@@ -20,7 +20,6 @@ pub(super) async fn invoke(
context: core::Context,
request: core::Request,
next: core::Next,
timeout: std::time::Duration,
) -> Result<core::Response, MiddlewareError> {
let Some(ports) = &entry.invocation else {
return if entry.failure_policy == PluginFailurePolicy::Delegate {
@@ -35,7 +34,9 @@ pub(super) async fn invoke(
.and_then(|settings| settings.runtime.as_ref())
.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);
let mut call = ports
.session
.context(Stage::Http, ports.session.budget(Stage::Http));
call.request = crate::adapter::request_context(context.extensions.request());
call.request_id = Some(context.request_id.clone());
call.resource_stream = true;
@@ -18,7 +18,6 @@ use std::{
collections::{BTreeMap, BTreeSet},
fmt,
sync::Arc,
time::Duration,
};
#[derive(Clone)]
@@ -168,7 +167,6 @@ fn compile_entry(
pub(crate) struct PluginMiddlewarePlan {
middleware: BTreeMap<MiddlewareMount, Box<[Arc<MiddlewareEntry>]>>,
middleware_timeout: Duration,
}
impl fmt::Debug for PluginMiddlewarePlan {
@@ -181,10 +179,7 @@ impl fmt::Debug for PluginMiddlewarePlan {
}
impl PluginMiddlewarePlan {
pub(crate) fn compile(
mut entries: Vec<MiddlewareEntry>,
maximum_call_timeout: Duration,
) -> Option<Arc<Self>> {
pub(crate) fn compile(mut entries: Vec<MiddlewareEntry>) -> Option<Arc<Self>> {
if entries.is_empty() {
return None;
}
@@ -208,7 +203,6 @@ impl PluginMiddlewarePlan {
.into_iter()
.map(|(mount, entries)| (mount, entries.into_boxed_slice()))
.collect(),
middleware_timeout: maximum_call_timeout,
}))
}
}
@@ -1,6 +1,6 @@
//! 在 Provider 发送前组合完整出站请求,并沿用模型数据面的调用资源
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
use gateway_admin::model::plugins::instances::PluginFailurePolicy;
use gateway_core::engine::middleware::{MiddlewareContext, MiddlewareError, outbound as core};
@@ -17,7 +17,6 @@ pub(super) async fn invoke(
context: MiddlewareContext,
request: core::Request,
next: core::Next,
timeout: Duration,
) -> Result<core::Request, MiddlewareError> {
let Some(ports) = &entry.invocation else {
return if entry.failure_policy == PluginFailurePolicy::Delegate {
@@ -54,7 +53,12 @@ pub(super) async fn invoke(
.get(),
},
};
let mut call = ports.session.context(Stage::Outbound, timeout);
let mut call = ports.session.context(
Stage::Outbound,
context
.deadline()
.bounded(ports.session.budget(Stage::Outbound)),
);
call.request = crate::adapter::request_context(context.extension_scope().request());
call.request_id = Some(input.metadata.request_id.clone());
call.attempt_id = Some(format!(
@@ -1,9 +1,6 @@
//! 请求与尝试中间件的 RPC 调用、失败回退和响应正文生命周期
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use std::sync::{Arc, Mutex};
use bytes::Bytes;
use futures::future::BoxFuture;
@@ -37,7 +34,6 @@ pub(super) async fn invoke(
context: MiddlewareContext,
request: MiddlewareRequest,
next: MiddlewareNext,
maximum_timeout: Duration,
) -> Result<MiddlewareResponse, MiddlewareError> {
let Some(invocation_ports) = &entry.invocation else {
return if entry.failure_policy
@@ -48,10 +44,7 @@ pub(super) async fn invoke(
Err(MiddlewareError::Fault)
};
};
if maximum_timeout.is_zero()
|| context.deadline().is_elapsed()
|| context.cancellation().is_cancelled()
{
if context.deadline().is_elapsed() || context.cancellation().is_cancelled() {
return Err(MiddlewareError::Fault);
}
let invocation = MiddlewareInvocation::new(&context, request, next, entry.instance_id.clone());
@@ -59,18 +52,21 @@ pub(super) async fn invoke(
Ok(projection) => projection,
Err(_) => return recover_rpc(&entry, &invocation, RpcError::Context(None)).await,
};
let stage = match entry.mount {
MiddlewareMount::Http
| MiddlewareMount::WebSocket
| MiddlewareMount::Service
| MiddlewareMount::Outbound => {
return Err(MiddlewareError::InvalidState);
}
MiddlewareMount::Request => Stage::Request,
MiddlewareMount::Attempt => Stage::Attempt,
};
let mut call_context = invocation_ports.session.context(
match entry.mount {
MiddlewareMount::Http
| MiddlewareMount::WebSocket
| MiddlewareMount::Service
| MiddlewareMount::Outbound => {
return Err(MiddlewareError::InvalidState);
}
MiddlewareMount::Request => Stage::Request,
MiddlewareMount::Attempt => Stage::Attempt,
},
maximum_timeout,
stage,
context
.deadline()
.bounded(invocation_ports.session.budget(stage)),
);
call_context.resource_stream = true;
call_context.request = crate::adapter::request_context(context.extension_scope().request());
@@ -16,7 +16,6 @@ pub(super) async fn invoke(
context: core::Context,
input: core::Value,
next: core::Next,
timeout: std::time::Duration,
) -> Result<core::Value, core::Error> {
let Some(ports) = &entry.invocation else {
return if entry.failure_policy == PluginFailurePolicy::Delegate {
@@ -26,7 +25,9 @@ pub(super) async fn invoke(
};
};
let invocation = Invocation::new(next);
let mut call = ports.session.context(Stage::Service, timeout);
let mut call = ports
.session
.context(Stage::Service, ports.session.budget(Stage::Service));
call.request = crate::adapter::request_context(context.extensions.request());
call.request_id = Some(context.request_id.clone());
let _binding = ports
@@ -16,7 +16,6 @@ pub(super) async fn invoke(
context: core::Context,
message: core::Message,
next: core::Next,
timeout: std::time::Duration,
) -> Result<Option<core::Message>, MiddlewareError> {
let Some(ports) = &entry.invocation else {
return if entry.failure_policy == PluginFailurePolicy::Delegate {
@@ -44,7 +43,9 @@ pub(super) async fn invoke(
.encode(message)
.map_err(|_| MiddlewareError::Fault)?,
};
let mut call = ports.session.context(Stage::WebSocket, timeout);
let mut call = ports
.session
.context(Stage::WebSocket, ports.session.budget(Stage::WebSocket));
call.request = crate::adapter::request_context(context.extensions.request());
call.request_id = Some(context.connection_id.clone());
let _binding = ports
@@ -3,7 +3,7 @@
use std::{
collections::BTreeMap,
sync::{Arc, Mutex},
time::{Duration, SystemTime, UNIX_EPOCH},
time::{SystemTime, UNIX_EPOCH},
};
use gateway_admin::model::{
@@ -31,7 +31,6 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
use crate::{RpcSession, adapter::scope::BindingScope};
const OBSERVATION_TIMEOUT: Duration = Duration::from_secs(2);
const OBSERVATION_ENVELOPE_RESERVE: usize = 16 * 1024;
#[derive(Clone)]
@@ -57,10 +56,11 @@ impl ObserverEntry {
event: Event,
payload: Vec<u8>,
extensions: gateway_core::engine::extensions::ExtensionCallScope,
timeout: Duration,
) {
let request_id = event.request_id();
let mut context = self.session.context(Stage::Observation, timeout);
let mut context = self
.session
.context(Stage::Observation, self.session.budget(Stage::Observation));
context.request = crate::adapter::request_context(extensions.request());
context.request_id = Some(request_id.to_owned());
let Ok(_scope) = self.callbacks.prepare_extensions(&context, extensions) else {
@@ -166,7 +166,6 @@ pub(crate) struct PluginObserverPlan {
websocket_queues: Arc<Mutex<BTreeMap<String, mpsc::Sender<QueuedWebSocketObservation>>>>,
websocket_queue_capacity: usize,
maximum_websocket_payload_bytes: usize,
timeout: Duration,
}
struct QueuedWebSocketObservation {
@@ -180,7 +179,6 @@ impl PluginObserverPlan {
pub(crate) fn compile(
mut entries: Vec<ObserverEntry>,
maximum_calls: usize,
maximum_call_timeout: Duration,
maximum_buffered_body_bytes: usize,
) -> Option<Arc<Self>> {
if entries.is_empty() {
@@ -205,7 +203,6 @@ impl PluginObserverPlan {
websocket_queues: Arc::new(Mutex::new(BTreeMap::new())),
websocket_queue_capacity: maximum_calls.clamp(1, 256),
maximum_websocket_payload_bytes,
timeout: maximum_call_timeout.min(OBSERVATION_TIMEOUT),
}))
}
@@ -326,7 +323,6 @@ impl RequestObserverPlan for PluginObserverPlan {
return;
};
let entries = Arc::clone(&self.entries);
let timeout = self.timeout;
handle.spawn(async move {
let _generation = generation;
let _slot = slot;
@@ -342,7 +338,6 @@ impl RequestObserverPlan for PluginObserverPlan {
Event::RequestCompleted(Box::new(wire_observation(&observation))),
Vec::new(),
observation.extension_scope().clone(),
timeout,
)
.await;
}
@@ -372,10 +367,8 @@ impl PluginObserverPlan {
let (sender, receiver) = mpsc::channel(self.websocket_queue_capacity);
queues.insert(request_id.to_owned(), sender.clone());
let entries = Arc::clone(&self.entries);
let timeout = self.timeout;
handle.spawn(dispatch_websocket_queue(
entries,
timeout,
generation,
worker_slot,
receiver,
@@ -386,7 +379,6 @@ impl PluginObserverPlan {
async fn dispatch_websocket_queue(
entries: Arc<[ObserverEntry]>,
timeout: Duration,
generation: ExtensionSetReference,
worker_slot: OwnedSemaphorePermit,
mut receiver: mpsc::Receiver<QueuedWebSocketObservation>,
@@ -422,7 +414,6 @@ async fn dispatch_websocket_queue(
Event::WebSocketResponse(Box::new(request)),
queued.payload.to_vec(),
queued.observation.extension_scope().clone(),
timeout,
)
.await;
}
@@ -3,7 +3,7 @@
mod retry;
mod route_schedule;
use std::{fmt, sync::Arc, time::Duration};
use std::{fmt, sync::Arc};
use gateway_admin::model::{
AdminError,
@@ -13,8 +13,6 @@ use gateway_plugin_sdk::{Capability, Manifest, Stage};
use crate::{RpcSession, adapter::scope::BindingScope, callback::PluginCallbacks};
const POLICY_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Clone)]
pub(crate) enum PolicyEntry {
Router(ModelRouterEntry),
@@ -219,7 +217,6 @@ pub(crate) struct PluginRequestPolicyPlan {
routers: Arc<[ModelRouterEntry]>,
schedulers: Arc<[AccountSchedulerEntry]>,
retries: Arc<[RetryEntry]>,
policy_timeout: Duration,
}
impl fmt::Debug for PluginRequestPolicyPlan {
@@ -234,10 +231,7 @@ impl fmt::Debug for PluginRequestPolicyPlan {
}
impl PluginRequestPolicyPlan {
pub(crate) fn compile(
entries: Vec<PolicyEntry>,
maximum_call_timeout: Duration,
) -> Result<Option<Arc<Self>>, AdminError> {
pub(crate) fn compile(entries: Vec<PolicyEntry>) -> Result<Option<Arc<Self>>, AdminError> {
let mut routers = Vec::new();
let mut schedulers = Vec::new();
let mut retries = Vec::new();
@@ -277,7 +271,6 @@ impl PluginRequestPolicyPlan {
routers: routers.into(),
schedulers: schedulers.into(),
retries: retries.into(),
policy_timeout: maximum_call_timeout.min(POLICY_TIMEOUT),
})))
}
}
@@ -17,11 +17,10 @@ use super::RetryEntry;
pub(super) fn decide(
entries: Arc<[RetryEntry]>,
input: RetryInput,
timeout: Duration,
) -> BoxFuture<'static, Result<RetryDecision, RequestPolicyFault>> {
Box::pin(async move {
let started = std::time::Instant::now();
for entry in entries.iter().filter(|entry| {
let started = tokio::time::Instant::now();
let matched = entries.iter().filter(|entry| {
!input.extension_scope.contains(&entry.instance_id)
&& entry.scope.matches_processing(
&input.client_key_id,
@@ -29,23 +28,32 @@ pub(super) fn decide(
Some(&input.facts.provider),
input.facts.model.as_deref(),
)
}) {
});
let timeout = matched
.clone()
.filter_map(|entry| entry.invocation.as_ref())
.map(|invocation| invocation.session.budget(Stage::Retry))
.max()
.unwrap_or_default()
.min(input.facts.remaining_deadline);
for entry in matched {
// 整条委托链共享预算,不能每个插件重新取得完整等待时间
let remaining = timeout
.min(input.facts.remaining_deadline)
.saturating_sub(started.elapsed());
let remaining = timeout.saturating_sub(started.elapsed());
if remaining.is_zero() {
break;
}
match call(entry, &input, remaining).await {
Ok(RetryDecision::Delegate) => {}
Ok(decision) => return Ok(decision),
Err(()) => tracing::warn!(
plugin_id = entry.plugin_id,
instance_id = entry.instance_id,
request_id = input.request_id.as_str(),
"插件重试策略无效或调用失败,委托后续策略"
),
match tokio::time::timeout(remaining, call(entry, &input, remaining)).await {
Err(_) => break,
Ok(result) => match result {
Ok(RetryDecision::Delegate) => {}
Ok(decision) => return Ok(decision),
Err(()) => tracing::warn!(
plugin_id = entry.plugin_id,
instance_id = entry.instance_id,
request_id = input.request_id.as_str(),
"插件重试策略无效或调用失败,委托后续策略"
),
},
}
}
Ok(RetryDecision::Delegate)
@@ -79,7 +87,10 @@ async fn call(
.unwrap_or(u64::MAX),
allowed_actions,
};
let mut context = invocation.session.context(Stage::Retry, timeout);
let mut context = invocation.session.context(
Stage::Retry,
timeout.min(invocation.session.budget(Stage::Retry)),
);
context.request = crate::adapter::request_context(input.extension_scope.request());
context.request_id = Some(request.request_id.clone());
context.attempt_id = Some(format!("{}:{}", request.request_id, request.attempt_index));
@@ -1,6 +1,6 @@
//! 插件模型路由与账号调度调用,以及请求事实的线协议投影
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::time::{SystemTime, UNIX_EPOCH};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use futures::future::BoxFuture;
@@ -38,7 +38,7 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
input: gateway_core::engine::policy::RetryInput,
) -> BoxFuture<'static, Result<gateway_core::engine::policy::RetryDecision, RequestPolicyFault>>
{
super::retry::decide(self.retries.clone(), input, self.policy_timeout)
super::retry::decide(self.retries.clone(), input)
}
fn route_model(
@@ -46,12 +46,11 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
input: ModelRouteInput,
) -> BoxFuture<'static, Result<ModelRouteDecision, RequestPolicyFault>> {
let routers = std::sync::Arc::clone(&self.routers);
let timeout = self.policy_timeout;
Box::pin(async move {
for entry in routers.iter().filter(|entry| {
!input.suppresses_plugin(&entry.instance_id) && entry.scope.matches_route(&input)
}) {
let result = route_with_entry(entry, &input, timeout).await;
let result = route_with_entry(entry, &input).await;
match result {
Ok(ModelRouteDecision::Unhandled) => continue,
Ok(decision) => return Ok(decision),
@@ -78,14 +77,13 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
input: AccountScheduleInput,
) -> BoxFuture<'static, Result<AccountScheduleDecision, RequestPolicyFault>> {
let schedulers = std::sync::Arc::clone(&self.schedulers);
let timeout = self.policy_timeout;
Box::pin(async move {
let Some(entry) = schedulers.iter().find(|entry| {
!input.suppresses_plugin(&entry.instance_id) && entry.scope.matches_schedule(&input)
}) else {
return Ok(AccountScheduleDecision::Delegate);
};
match schedule_with_entry(entry, &input, timeout).await {
match schedule_with_entry(entry, &input).await {
Ok(decision) => Ok(decision),
Err(())
if entry.failure_policy
@@ -108,7 +106,6 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
async fn route_with_entry(
entry: &ModelRouterEntry,
input: &ModelRouteInput,
timeout: Duration,
) -> Result<ModelRouteDecision, ()> {
let invocation = entry.invocation.as_ref().ok_or(())?;
let projection = operation_projection(input.operation())?;
@@ -126,7 +123,9 @@ async fn route_with_entry(
.collect(),
headers,
};
let mut context = invocation.session.context(Stage::Routing, timeout);
let mut context = invocation
.session
.context(Stage::Routing, invocation.session.budget(Stage::Routing));
context.request = crate::adapter::request_context(input.extension_scope().request());
context.request_id = Some(input.request_id().as_str().to_owned());
let _network_scope = invocation
@@ -172,7 +171,6 @@ async fn route_with_entry(
async fn schedule_with_entry(
entry: &AccountSchedulerEntry,
input: &AccountScheduleInput,
timeout: Duration,
) -> Result<AccountScheduleDecision, ()> {
let invocation = entry.invocation.as_ref().ok_or(())?;
let candidates = input.candidates();
@@ -197,7 +195,10 @@ async fn schedule_with_entry(
})
.collect(),
};
let mut context = invocation.session.context(Stage::Scheduling, timeout);
let mut context = invocation.session.context(
Stage::Scheduling,
invocation.session.budget(Stage::Scheduling),
);
context.request = crate::adapter::request_context(input.extension_scope().request());
context.request_id = Some(input.request_id().as_str().to_owned());
context.attempt_id = Some(format!(
@@ -167,7 +167,7 @@ impl Execution {
let timeout = invocation
.context
.deadline()
.bounded(self.adapter.session.maximum_call_timeout());
.bounded(self.adapter.session.budget(Stage::Upstream));
if timeout.is_zero() || invocation.context.cancellation().is_cancelled() {
return Err(ProviderError::new(
ProviderErrorKind::Cancelled,
@@ -1,6 +1,6 @@
//! 校验并准备插件上游适配声明与能力绑定
use std::{sync::Arc, time::Duration};
use std::sync::Arc;
use gateway_admin::model::{
AdminError,
@@ -56,7 +56,7 @@ pub(crate) async fn prepare(
let reply = session
.call(
REGISTER_METHOD,
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
vec![],
)
@@ -16,18 +16,22 @@ use gateway_admin::{
use gateway_plugin_sdk::{Handshake, Stage, call::registration::Registration};
use std::{sync::Arc, time::Duration};
const PROCESS_PREPARATION_TIMEOUT: Duration = Duration::from_secs(30);
struct StartedInstance {
instance: PluginInstance,
package: Arc<crate::PreparedPackage>,
callbacks: Arc<PluginCallbacks>,
prepared: PreparedInstance,
}
impl PluginRuntime {
pub(super) async fn prepare_instance(
async fn start_instance(
&self,
instance: PluginInstance,
target_revision: Revision,
request_body_limit: Option<usize>,
) -> Result<Arc<PreparedInstance>, AdminError> {
let mut observer_entries = Vec::new();
let mut commands = Vec::new();
let mut management = Vec::new();
let mut policy_entries = Vec::new();
let mut authentication_entries = Vec::new();
) -> Result<StartedInstance, AdminError> {
let authorization_binding = instance_fingerprint(&instance)?;
if self.restart_circuit_is_open(&RestartIdentity::new(
instance.id.clone(),
@@ -109,7 +113,6 @@ impl PluginRuntime {
crate::compatibility::report(&manifest, &instance_id)?;
let restart_identity =
RestartIdentity::new(instance_id.clone(), authorization_binding.clone());
let bindings = instance.bindings.clone();
let owner = self
.state
.load_owner(
@@ -181,23 +184,69 @@ impl PluginRuntime {
lifecycle,
)
.await
.map_err(|_| AdminError::unavailable("插件进程握手失败"))?,
.map_err(|error| match error {
crate::RpcError::Budget(gateway_plugin_sdk::CallBudgetError::Invalid {
stage,
milliseconds,
}) => AdminError::invalid(format!(
"插件阶段 {stage:?} 的调用预算无效:{milliseconds} 毫秒"
)),
_ => AdminError::unavailable("插件进程握手失败"),
})?,
);
let mut prepared = PreparedInstance {
let prepared = PreparedInstance {
instance_id: instance_id.clone(),
artifact_sha256: instance.artifact_sha256.clone(),
revision: instance.revision,
session: session.clone(),
session,
private_state: private_state.clone(),
maintenance: manifest
.contributes
.contains_key(&gateway_plugin_sdk::Capability::Maintenance),
contributions: PreparedContributions::default(),
};
Ok(StartedInstance {
instance,
package,
callbacks,
prepared,
})
}
pub(super) async fn prepare_instance(
&self,
instance: PluginInstance,
target_revision: Revision,
request_body_limit: Option<usize>,
) -> Result<Arc<PreparedInstance>, AdminError> {
// 启动控制期限只包住制品准备与握手,Ready 后每次注册使用协商预算
let StartedInstance {
instance,
package,
callbacks,
mut prepared,
} = tokio::time::timeout(
PROCESS_PREPARATION_TIMEOUT,
self.start_instance(instance, target_revision, request_body_limit),
)
.await
.map_err(|_| AdminError::unavailable("插件启动超时"))??;
let session = &prepared.session;
let manifest = package.package().manifest();
let plugin_id = manifest
.plugin_id()
.map_err(|_| AdminError::invalid("插件身份无效"))?;
let instance_id = &instance.id;
let bindings = &instance.bindings;
let mut observer_entries = Vec::new();
let mut commands = Vec::new();
let mut management = Vec::new();
let mut policy_entries = Vec::new();
let mut authentication_entries = Vec::new();
let registration = session
.call(
"plugin.register",
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
vec![],
)
@@ -211,7 +260,7 @@ impl PluginRuntime {
commands.extend(
crate::adapter::command_line::prepare(
&instance,
&manifest,
manifest,
session.clone(),
callbacks.clone(),
)
@@ -228,8 +277,8 @@ impl PluginRuntime {
management.push(entry);
}
if let Some(entry) = crate::adapter::frontend_authentication::prepare_entry(
&manifest,
&bindings,
manifest,
bindings,
session.clone(),
callbacks.clone(),
)
@@ -238,35 +287,34 @@ impl PluginRuntime {
authentication_entries.push(entry);
}
if let Some(entry) = crate::adapter::observer::compile_entry(
&manifest,
manifest,
&plugin_id,
&instance_id,
&bindings,
instance_id,
bindings,
session.clone(),
callbacks.clone(),
)? {
observer_entries.push(entry);
}
policy_entries.extend(crate::adapter::policy::compile_entries(
&manifest,
&instance_id,
&bindings,
manifest,
instance_id,
bindings,
session.clone(),
callbacks.clone(),
)?);
let middleware_entries = crate::adapter::middleware::compile_entries(
&manifest,
&instance_id,
&bindings,
manifest,
instance_id,
bindings,
session.clone(),
callbacks.clone(),
)?;
let model_aliases =
crate::adapter::catalog::prepare(&manifest, &instance, &session).await?;
let model_aliases = crate::adapter::catalog::prepare(manifest, &instance, session).await?;
let upstream_entries = crate::adapter::upstream_adapter::prepare(
&manifest,
manifest,
&instance,
Arc::clone(&session),
Arc::clone(session),
Arc::clone(&callbacks),
)
.await?;
@@ -1,8 +1,11 @@
//! 对账只从已发布集合开始;通知合并,实例串行,失败和停用不阻塞其他实例
use super::{PluginRuntime, set::PreparedSet};
use super::{
PluginRuntime,
set::{PreparedInstance, PreparedSet},
};
use crate::RpcSession;
use futures::{StreamExt as _, future::BoxFuture};
use futures::{StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
use gateway_core::{
lifecycle::CancellationToken,
runtime::RuntimeSnapshotHandle,
@@ -13,12 +16,51 @@ use gateway_core::{
},
};
use gateway_plugin_sdk::Stage;
use std::{sync::Arc, time::Duration};
use tokio::{sync::Notify, task::JoinSet};
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use tokio::{sync::Notify, task::JoinHandle};
const RECONCILE_INTERVAL: Duration = Duration::from_secs(30);
const RETRY_DELAY: Duration = Duration::from_secs(5);
const CALL_TIMEOUT: Duration = Duration::from_secs(30);
struct InstanceTask {
instance: Arc<PreparedInstance>,
dirty: Arc<Notify>,
cancellation: CancellationToken,
task: JoinHandle<()>,
}
impl InstanceTask {
fn start(instance: Arc<PreparedInstance>) -> Self {
let dirty = Arc::new(Notify::new());
dirty.notify_one();
let cancellation = CancellationToken::new();
let task = tokio::spawn(reconcile(
Arc::clone(&instance.session),
Arc::clone(&dirty),
cancellation.clone(),
));
Self {
instance,
dirty,
cancellation,
task,
}
}
async fn stop(mut self) {
self.cancellation.cancel();
let _ = (&mut self.task).await;
// 宿主资源在取消时同步撤销,替换任务还须等对端确认或进程退出
self.instance.session.settle_cancellations().await;
}
}
impl Drop for InstanceTask {
fn drop(&mut self) {
self.cancellation.cancel();
self.task.abort();
}
}
struct MaintenanceTask {
runtime: Arc<PluginRuntime>,
@@ -59,18 +101,12 @@ impl DaemonTask for MaintenanceTask {
let mut publications = self.snapshots.subscribe_publications();
let mut active: Option<Arc<PreparedSet>> = None;
let mut revision = None;
let mut stop = CancellationToken::new();
let mut tasks = JoinSet::new();
let mut notifications = Vec::new();
let mut tasks = BTreeMap::<String, InstanceTask>::new();
loop {
let snapshot = self.snapshots.acquire().ok();
let reference = snapshot.as_ref().and_then(|s| s.extensions());
let same_set = active.as_ref().map(|s| &s.id) == reference.map(|s| s.id());
if !same_set {
stop.cancel();
while tasks.join_next().await.is_some() {}
notifications.clear();
stop = CancellationToken::new();
active = match reference {
Some(reference) => {
Some(self.runtime.prepared_set(reference).await.map_err(|_| {
@@ -79,17 +115,37 @@ impl DaemonTask for MaintenanceTask {
}
None => None,
};
if let Some(set) = &active {
for instance in set.sessions.iter().filter(|i| i.maintenance) {
let dirty = Arc::new(Notify::new());
dirty.notify_one();
notifications.push(dirty.clone());
tasks.spawn(reconcile(instance.session.clone(), dirty, stop.clone()));
}
let desired = active
.iter()
.flat_map(|set| &set.sessions)
.filter(|instance| instance.maintenance)
.map(|instance| (instance.instance_id.clone(), Arc::clone(instance)))
.collect::<BTreeMap<_, _>>();
let retired = tasks
.extract_if(.., |id, task| {
// 发布层只复用完全相同且就绪的实例,Arc 身份也包含会话 incarnation
!desired
.get(id)
.is_some_and(|instance| Arc::ptr_eq(instance, &task.instance))
})
.map(|(_, task)| task)
.collect::<Vec<_>>();
for task in &retired {
task.cancellation.cancel();
}
} else if revision != snapshot.as_ref().map(|s| s.revision()) {
for dirty in &notifications {
dirty.notify_one();
futures::future::join_all(retired.into_iter().map(InstanceTask::stop)).await;
for task in tasks.values() {
task.dirty.notify_one();
}
for (id, instance) in desired {
tasks
.entry(id)
.or_insert_with(|| InstanceTask::start(instance));
}
}
if same_set && revision != snapshot.as_ref().map(|s| s.revision()) {
for task in tasks.values() {
task.dirty.notify_one();
}
}
revision = snapshot.as_ref().map(|s| s.revision());
@@ -101,22 +157,39 @@ impl DaemonTask for MaintenanceTask {
biased;
() = cancellation.cancelled() => break,
changed = publications.next() => if changed.is_none() { break; },
result = tasks.join_next(), if !tasks.is_empty() => {
if result.is_some() {
stop.cancel();
while tasks.join_next().await.is_some() {}
result = next_finished(&mut tasks), if !tasks.is_empty() => {
if let Some(instance_id) = result {
tasks.remove(&instance_id);
stop_all(tasks).await;
return Err(WorkerTaskError::safe("plugin maintenance task stopped unexpectedly"));
}
}
}
}
stop.cancel();
while tasks.join_next().await.is_some() {}
stop_all(tasks).await;
Ok(())
})
}
}
async fn next_finished(tasks: &mut BTreeMap<String, InstanceTask>) -> Option<String> {
let mut pending = tasks
.iter_mut()
.map(|(id, task)| async move {
let _ = (&mut task.task).await;
id.clone()
})
.collect::<FuturesUnordered<_>>();
pending.next().await
}
async fn stop_all(tasks: BTreeMap<String, InstanceTask>) {
for task in tasks.values() {
task.cancellation.cancel();
}
futures::future::join_all(tasks.into_values().map(InstanceTask::stop)).await;
}
async fn reconcile(session: Arc<RpcSession>, dirty: Arc<Notify>, cancellation: CancellationToken) {
loop {
tokio::select! {
@@ -125,21 +198,38 @@ async fn reconcile(session: Arc<RpcSession>, dirty: Arc<Notify>, cancellation: C
() = dirty.notified() => {},
() = tokio::time::sleep(RECONCILE_INTERVAL) => {},
}
let context = session.context(Stage::Maintenance, CALL_TIMEOUT);
let context = session.context(Stage::Maintenance, session.budget(Stage::Maintenance));
let instance_id = context.instance_id.clone();
let resource_scope_id = context.resource_scope_id.clone();
let budget_ms = context.timeout_ms;
let started = tokio::time::Instant::now();
let result = tokio::select! {
biased;
() = cancellation.cancelled() => return,
() = cancellation.cancelled() => {
tracing::info!(target: "plugin.maintenance", %instance_id, %resource_scope_id, budget_ms,
budget_source = ?session.budget_source(Stage::Maintenance),
elapsed_ms = started.elapsed().as_millis() as u64,
reason = "cancelled", "plugin maintenance ended");
return;
},
result = session.call("plugin.reconcile", context, serde_json::json!({}), Vec::new()) => result,
};
if result.is_err() {
tracing::warn!(%instance_id, "plugin maintenance failed; retrying after bounded delay");
if let Err(error) = result {
tracing::warn!(target: "plugin.maintenance", %instance_id, %resource_scope_id, budget_ms,
budget_source = ?session.budget_source(Stage::Maintenance),
elapsed_ms = started.elapsed().as_millis() as u64,
reason = error.diagnostic().0, "plugin maintenance failed; retrying after bounded delay");
tokio::select! {
biased;
() = cancellation.cancelled() => return,
() = tokio::time::sleep(RETRY_DELAY) => {},
}
dirty.notify_one();
} else {
tracing::info!(target: "plugin.maintenance", %instance_id, %resource_scope_id, budget_ms,
budget_source = ?session.budget_source(Stage::Maintenance),
elapsed_ms = started.elapsed().as_millis() as u64,
reason = "completed", "plugin maintenance ended");
}
}
}
@@ -27,15 +27,13 @@ use gateway_core::{
observation::RequestObserverExtensionIndex,
policy::RequestPolicyExtensionIndex,
},
lifecycle::CancellationToken,
provider_ports::OAuthPendingFlowPort,
};
use std::{
collections::BTreeMap,
path::PathBuf,
sync::{
Arc, Mutex as SyncMutex, Weak,
atomic::{AtomicBool, Ordering},
},
sync::{Arc, Mutex as SyncMutex, Weak},
time::Duration,
};
use tokio::sync::{Mutex, Semaphore};
@@ -62,7 +60,7 @@ pub struct PluginRuntime {
pub(super) prepared_instances: Mutex<BTreeMap<String, Weak<PreparedInstance>>>,
pub(super) preparation_diagnostics: SyncMutex<BTreeMap<u64, PreparationDiagnostic>>,
pub(super) restart_circuits: Arc<RestartCircuits>,
pub(super) shutting_down: Arc<AtomicBool>,
pub(super) shutting_down: CancellationToken,
shutdown_lock: Mutex<()>,
pub(super) processes: Arc<gateway_host::process::ProcessSupervisor>,
pub(super) validators: Arc<Semaphore>,
@@ -105,7 +103,7 @@ impl PluginRuntime {
prepared_instances: Mutex::new(BTreeMap::new()),
preparation_diagnostics: SyncMutex::new(BTreeMap::new()),
restart_circuits: RestartCircuits::new(restart_circuit),
shutting_down: Arc::new(AtomicBool::new(false)),
shutting_down: CancellationToken::new(),
shutdown_lock: Mutex::new(()),
processes,
validators: Arc::new(Semaphore::new(MAXIMUM_PARALLEL_PREPARATIONS)),
@@ -182,7 +180,7 @@ impl PluginRuntime {
/// 调用方必须先停止接收 HTTP 与 Worker;这里拒绝新候选并等待全部插件 I/O 退出
pub async fn shutdown(&self) {
self.shutting_down.store(true, Ordering::Release);
self.shutting_down.cancel();
let _shutdown = self.shutdown_lock.lock().await;
let _prepare = self.prepare_lock.lock().await;
let sets = {
@@ -10,18 +10,12 @@ use gateway_core::{
extensions::ExecutionExtensionPlans, observation::RequestObserverPlan,
policy::RequestPolicyPlan,
},
lifecycle::CancellationToken,
routing::extensions::{ExtensionSetId, ExtensionSetLease},
};
use secrecy::ExposeSecret as _;
use sha2::{Digest as _, Sha256};
use std::{
collections::BTreeMap,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use std::{collections::BTreeMap, sync::Arc, time::Duration};
pub(super) struct PreparedSet {
pub(super) id: ExtensionSetId,
@@ -32,7 +26,7 @@ pub(super) struct PreparedSet {
Option<Arc<dyn gateway_core::engine::authentication::FrontendAuthenticationPlan>>,
pub(super) sessions: Vec<Arc<PreparedInstance>>,
pub(super) failures: BTreeMap<String, PluginInstanceRuntimeFailure>,
pub(super) shutting_down: Arc<AtomicBool>,
pub(super) shutting_down: CancellationToken,
pub(super) commands: Vec<Arc<crate::adapter::command_line::PluginCommand>>,
pub(super) management: Vec<crate::adapter::management::ManagementEntry>,
pub(super) model_aliases: Vec<gateway_core::routing::ContributedModelAlias>,
@@ -94,7 +88,7 @@ impl ExtensionSetLease for PreparedSet {
fn can_serve(&self) -> bool {
// 发布的是可用能力和故障绑定组成的完整计划;单个进程退出不能使原生转发失效
!self.shutting_down.load(Ordering::Acquire)
!self.shutting_down.is_cancelled()
}
}
@@ -26,8 +26,7 @@ use gateway_core::{
};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, Weak, atomic::Ordering},
time::Duration,
sync::{Arc, Weak},
};
impl PluginRuntime {
@@ -39,9 +38,11 @@ impl PluginRuntime {
) -> Result<ExtensionSetReference, AdminError> {
let target_revision = snapshot.config_revision.get();
self.record_preparation(target_revision, PreparationDiagnostic::Preparing);
let result = self
.prepare_inner(snapshot, required_revision, request_body_limit)
.await;
let result = tokio::select! {
biased;
() = self.shutting_down.cancelled() => Err(AdminError::unavailable("插件运行时正在关闭")),
result = self.prepare_inner(snapshot, required_revision, request_body_limit) => result,
};
match &result {
Ok(reference) => self.record_preparation(
target_revision,
@@ -63,7 +64,7 @@ impl PluginRuntime {
required_revision: Option<Revision>,
request_body_limit: Option<usize>,
) -> Result<ExtensionSetReference, AdminError> {
if self.shutting_down.load(Ordering::Acquire) {
if self.shutting_down.is_cancelled() {
return Err(AdminError::unavailable("插件运行时正在关闭"));
}
if snapshot.instances.len() > 64 {
@@ -72,7 +73,7 @@ impl PluginRuntime {
let fingerprint = fingerprint(&snapshot)?;
// 准备串行化与弱索引分锁;诊断和已发布请求不会等待外部 I/O
let _prepare = self.prepare_lock.lock().await;
if self.shutting_down.load(Ordering::Acquire) {
if self.shutting_down.is_cancelled() {
return Err(AdminError::unavailable("插件运行时正在关闭"));
}
let existing = {
@@ -125,17 +126,12 @@ impl PluginRuntime {
let result = if let Some(instance) = reusable {
Ok(instance)
} else {
tokio::time::timeout(
// 沿用准备阶段原有的 30 秒预算;打包校验与能力恢复也在预算内
Duration::from_secs(30),
self.prepare_instance(
instance.clone(),
snapshot.config_revision,
request_body_limit,
),
self.prepare_instance(
instance.clone(),
snapshot.config_revision,
request_body_limit,
)
.await
.unwrap_or_else(|_| Err(AdminError::unavailable("插件启动超时")))
};
(instance, identity, result)
},
@@ -208,7 +204,6 @@ impl PluginRuntime {
.rpc_limits
.maximum_calls
.min(self.config.rpc_limits.maximum_executing_calls),
self.config.rpc_limits.maximum_call_timeout,
self.config.rpc_limits.maximum_buffered_body_bytes,
)
.map(|plan| {
@@ -217,21 +212,16 @@ impl PluginRuntime {
.map_err(|_| AdminError::invalid("插件观察计划注册冲突"))
})
.transpose()?;
let policies = crate::adapter::policy::PluginRequestPolicyPlan::compile(
policy_entries,
self.config.rpc_limits.maximum_call_timeout,
)?
.map(|plan| {
self.policies
.register(id.clone(), plan)
.map_err(|_| AdminError::invalid("插件请求策略计划注册冲突"))
})
.transpose()?;
let middleware = crate::adapter::middleware::PluginMiddlewarePlan::compile(
middleware_entries,
self.config.rpc_limits.maximum_call_timeout,
)
.map(|plan| plan as Arc<dyn gateway_core::engine::middleware::MiddlewarePlan>);
let policies = crate::adapter::policy::PluginRequestPolicyPlan::compile(policy_entries)?
.map(|plan| {
self.policies
.register(id.clone(), plan)
.map_err(|_| AdminError::invalid("插件请求策略计划注册冲突"))
})
.transpose()?;
let middleware =
crate::adapter::middleware::PluginMiddlewarePlan::compile(middleware_entries)
.map(|plan| plan as Arc<dyn gateway_core::engine::middleware::MiddlewarePlan>);
let upstream_adapters =
crate::adapter::upstream_adapter::PluginUpstreamAdapterPlan::compile(upstream_entries);
let execution = if middleware.is_some() || upstream_adapters.is_some() {
@@ -252,7 +242,6 @@ impl PluginRuntime {
let authentication =
crate::adapter::frontend_authentication::PluginFrontendAuthenticationPlan::compile(
authentication_entries,
self.config.rpc_limits.maximum_call_timeout,
)?
.map(|plan| {
self.authentications
@@ -90,9 +90,10 @@ impl PluginRuntime {
.session
.call(
"plugin.state.migrate",
instance
.session
.context(Stage::Configuration, Duration::from_secs(30)),
instance.session.context(
Stage::Configuration,
instance.session.budget(Stage::Configuration),
),
serde_json::json!({}),
serde_json::to_vec(&request)
.map_err(|_| AdminError::internal("状态迁移批次无法编码"))?,
@@ -207,7 +208,11 @@ impl gateway_admin::ports::plugins::PluginStateLifecycle for PluginRuntime {
prepared: &ExtensionSetReference,
transition: PluginStateTransition,
) -> Result<(), AdminError> {
self.run_state_migration(prepared, transition).await
tokio::select! {
biased;
() = self.shutting_down.cancelled() => Err(AdminError::unavailable("插件运行时正在关闭")),
result = self.run_state_migration(prepared, transition) => result,
}
}
}
pub(super) fn map_state_admin_error(
@@ -12,10 +12,13 @@ use std::{
use futures::future::BoxFuture;
use gateway_host::process::{ProcessControl, ProcessSpec, ProcessStartError, ProcessSupervisor};
use gateway_plugin_sdk::{
CallContext, Frame, Handshake, Message, PROTOCOL_VERSION, PluginFault, Stage,
CallBudgetError, CallBudgetSource, CallContext, Frame, Handshake, Message, PROTOCOL_VERSION,
PluginFault, ResolvedCallBudgets, Stage,
client::{CallDeadline, CallDeadlinePause, EncodedFrame, read_frame, write_frame},
};
use tokio::sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch};
use tokio::sync::{
Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch,
};
use crate::{PreparedPackage, RpcStream, stream::StreamIngress};
@@ -32,7 +35,6 @@ pub struct RpcLimits {
pub maximum_queue_timeout: Duration,
pub maximum_callbacks: usize,
pub handshake_timeout: Duration,
pub maximum_call_timeout: Duration,
pub maximum_stderr_bytes: usize,
}
@@ -46,7 +48,6 @@ impl Default for RpcLimits {
maximum_queue_timeout: Duration::from_secs(30),
maximum_callbacks: 64,
handshake_timeout: Duration::from_secs(5),
maximum_call_timeout: Duration::from_secs(120),
maximum_stderr_bytes: 64 * 1024,
}
}
@@ -58,6 +59,8 @@ pub enum RpcError {
Start(#[source] ProcessStartError),
#[error("plugin handshake does not match the prepared instance")]
Handshake(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin call budget negotiation failed: {0}")]
Budget(#[from] CallBudgetError),
#[error("plugin protocol is invalid")]
Protocol(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin returned an invalid response at {0:?}")]
@@ -89,6 +92,7 @@ impl RpcError {
match self {
RpcError::Start(_) => ("process_start", "插件进程启动失败"),
RpcError::Handshake(_) => ("handshake", "插件握手失败"),
RpcError::Budget(_) => ("call_budget", "插件调用预算协商失败"),
RpcError::Protocol(_) => ("protocol", "插件协议错误"),
RpcError::InvalidResponse(stage, _) => (
"invalid_response",
@@ -191,6 +195,7 @@ pub trait CallbackHandler: Send + Sync {
pub struct RpcSession {
shared: Arc<Shared>,
handshake: Handshake,
budgets: ResolvedCallBudgets,
data: mpsc::Sender<EncodedFrame>,
slots: Arc<Semaphore>,
next_call: AsyncMutex<u64>,
@@ -222,6 +227,7 @@ pub(crate) struct Shared {
callback_slots: Arc<Semaphore>,
next_slots: Arc<Semaphore>,
execution: ExecutionCapacity,
cancellations_changed: Notify,
}
struct State {
@@ -295,6 +301,7 @@ impl Shared {
}
self.stopped.send_replace(true);
self.process.stop();
self.cancellations_changed.notify_waiters();
}
fn expect_stop(&self) {
@@ -512,6 +519,8 @@ impl Shared {
return Err(RpcError::Protocol(None));
}
state.calls.remove(&id);
drop(state);
self.cancellations_changed.notify_waiters();
Ok(())
}
@@ -568,8 +577,36 @@ impl Shared {
}
impl RpcSession {
pub(crate) const fn maximum_call_timeout(&self) -> Duration {
self.limits.maximum_call_timeout
pub fn budget(&self, stage: Stage) -> Duration {
self.budgets.budget(stage)
}
#[must_use]
pub(crate) fn budget_source(&self, stage: Stage) -> CallBudgetSource {
self.budgets.source(stage)
}
/// 替换维护任务前等待已取消的调用确认;未确认仍沿用会话的有界故障回收
pub(crate) async fn settle_cancellations(&self) {
let ids: Vec<_> = self
.shared
.state()
.calls
.iter()
.filter_map(|(id, call)| call.cancellation.is_some().then_some(*id))
.collect();
loop {
let changed = self.shared.cancellations_changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
if !ids.iter().any(|id| self.shared.is_cancelling(*id)) {
break;
}
changed.await;
}
if !self.is_ready() {
self.shared.process.exited().await;
}
}
pub async fn start(
@@ -620,7 +657,6 @@ impl RpcSession {
|| limits.maximum_buffered_body_bytes < 1024
|| limits.maximum_buffered_body_bytes > 16 * 1024 * 1024
|| limits.handshake_timeout.is_zero()
|| limits.maximum_call_timeout.is_zero()
{
return Err(RpcError::Context(None));
}
@@ -691,11 +727,12 @@ impl RpcSession {
Message::Ready {
protocol_version,
incarnation,
call_budgets,
} if protocol_version == handshake.protocol_version
&& incarnation == handshake.incarnation
&& reply.payload.is_empty() =>
{
Ok(())
ResolvedCallBudgets::resolve(&call_budgets).map_err(RpcError::Budget)
}
_ => Err(RpcError::Handshake(None)),
}
@@ -703,15 +740,18 @@ impl RpcSession {
.await
.map_err(|_| RpcError::Timeout)
.and_then(|result| result);
if let Err(error) = ready {
if let Some(lifecycle) = &lifecycle {
// 只有 Ready 之后的存活才可复位预算;长时间卡在握手不等于稳定运行
lifecycle.fail(Duration::ZERO);
let budgets = match ready {
Ok(budgets) => budgets,
Err(error) => {
if let Some(lifecycle) = &lifecycle {
// 只有 Ready 之后的存活才可复位预算;长时间卡在握手不等于稳定运行
lifecycle.fail(Duration::ZERO);
}
process.stop();
process.exited().await;
return Err(error);
}
process.stop();
process.exited().await;
return Err(error);
}
};
let (data, data_receiver) = mpsc::channel(limits.maximum_calls);
let (control, control_receiver) =
mpsc::channel(limits.maximum_callbacks + 2 * limits.maximum_calls + 8);
@@ -737,6 +777,7 @@ impl RpcSession {
limits.maximum_queued_calls,
limits.maximum_queue_timeout,
),
cancellations_changed: Notify::new(),
});
dispatch::start(
input,
@@ -760,6 +801,7 @@ impl RpcSession {
Ok(Self {
shared,
handshake,
budgets,
data,
slots: Arc::new(Semaphore::new(limits.maximum_calls)),
next_call: AsyncMutex::new(1),
@@ -799,8 +841,8 @@ impl RpcSession {
#[must_use]
pub fn context(&self, stage: gateway_plugin_sdk::Stage, timeout: Duration) -> CallContext {
// 宿主生成的上下文受本会话预算约束;固定注册期限不能使较小的总预算无法启动
let timeout = timeout.min(self.limits.maximum_call_timeout);
// 调用方可以传入更短的外部剩余期限,不能扩大实例已协商的阶段预算
let timeout = timeout.min(self.budget(stage));
CallContext {
request: None,
call_id: 0,
@@ -823,7 +865,7 @@ impl RpcSession {
|| context.generation != self.handshake.generation
|| context.incarnation != self.handshake.incarnation
|| context.timeout_ms == 0
|| Duration::from_millis(context.timeout_ms) > self.limits.maximum_call_timeout
|| Duration::from_millis(context.timeout_ms) > self.budget(context.stage)
|| method.is_empty()
|| method.len() > 128
{
@@ -6,6 +6,120 @@ use serde_json::json;
use crate::support::environment::Environment;
#[derive(Default)]
struct BudgetAccountAccess {
calls: std::sync::atomic::AtomicUsize,
first_started: tokio::sync::Notify,
second_started: tokio::sync::Notify,
}
#[async_trait::async_trait]
impl gateway_admin::ports::plugin_accounts::PluginAccountAccess for BudgetAccountAccess {
async fn list(
&self,
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
) -> Result<
gateway_admin::model::provider_credentials::PluginAccountPage,
gateway_admin::model::AdminError,
> {
panic!("账号提交不应读取账号列表")
}
async fn get_runtime(
&self,
_: &gateway_core::account::ProviderAccountId,
) -> Result<gateway_admin::model::accounts::AccountRecord, gateway_admin::model::AdminError>
{
panic!("创建账号不应读取已有账号")
}
async fn get_credential(
&self,
_: &gateway_core::account::ProviderAccountId,
) -> Result<
gateway_admin::model::provider_credentials::PluginAccountCredential,
gateway_admin::model::AdminError,
> {
panic!("账号提交不应读取凭据")
}
async fn save(
&self,
_: gateway_admin::model::provider_credentials::PreparedPluginAccountSave,
_: &gateway_admin::model::MutationContext,
) -> Result<
gateway_admin::model::provider_credentials::PluginAccountSaveResult,
gateway_admin::model::AdminError,
> {
if self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
self.first_started.notify_one();
tokio::time::sleep(std::time::Duration::from_secs(70)).await;
Ok(
gateway_admin::model::provider_credentials::PluginAccountSaveResult {
config_revision: gateway_admin::model::Revision::new(2).unwrap(),
account_id: gateway_core::account::ProviderAccountId::new(
"acct_command_budget",
)
.unwrap(),
credential_revision: gateway_admin::model::Revision::new(1).unwrap(),
},
)
} else {
self.second_started.notify_one();
std::future::pending().await
}
}
}
#[tokio::test]
async fn account_commits_share_the_commands_declared_deadline_and_report_partial_completion() {
use gateway_admin::ports::plugin_accounts::PluginAccountAccess;
use std::{sync::Arc, time::Duration};
let account = json!({"action":"create","provider_id":"openai","facts":{
"name":"预算测试","authentication_kind":"api_key","material":{"key":"fixture-only"}
}});
let config = json!({
"call_budgets_ms":{"command_line":180_000},
"command_registration":{"commands":[{"name":"save","description":"提交账号"}]},
"command_result":{"stdout":"","stderr":"","exit_code":0,"accounts":[account, account]},
});
let (_directory, _store, runtime) = super::setup(config).await;
let probe = Arc::new(BudgetAccountAccess::default());
let accounts: Arc<dyn PluginAccountAccess> = probe.clone();
runtime.bind_account_ports(&accounts).unwrap();
let commands = runtime.prepare_command_line().await.unwrap();
let task = tokio::spawn(async move {
let result = commands.execute("commands", "save", &[]).await;
(commands, result)
});
tokio::time::timeout(Duration::from_secs(5), probe.first_started.notified())
.await
.unwrap();
tokio::time::pause();
tokio::time::advance(Duration::from_secs(121)).await;
probe.second_started.notified().await;
assert!(
!task.is_finished(),
"账号提交沿用声明预算,不能被默认期限截断"
);
tokio::time::advance(Duration::from_secs(60)).await;
let (commands, result) = task.await.unwrap();
tokio::time::resume();
assert!(
matches!(
result,
Err(PluginCommandError::Incomplete {
saved_accounts: 1,
reason: "超过命令总期限",
})
),
"后续账号提交不能重新获得完整预算"
);
commands.shutdown().await;
}
#[tokio::test]
async fn model_requests_have_a_body_limit_independent_of_the_response_buffer() {
use gateway_plugin_sdk::call::model::request_payload_limit;
@@ -174,8 +174,7 @@ async fn failed_or_cancelled_commands_are_not_replayed_and_children_are_reaped()
} else {
// 正常完成注册并确认命令已进入子进程后,只推进执行期限
tokio::time::pause();
tokio::time::advance(limits.maximum_call_timeout + Duration::from_millis(1))
.await;
tokio::time::advance(Duration::from_secs(120) + Duration::from_millis(1)).await;
}
}
};
@@ -209,3 +208,46 @@ async fn failed_or_cancelled_commands_are_not_replayed_and_children_are_reaped()
}
}
}
#[tokio::test]
async fn command_declared_budget_survives_the_default_and_expires_at_its_own_deadline() {
use std::time::Duration;
let mut config = configuration(json!([]));
config["command_wait"] = json!(true);
config["call_budgets_ms"] = json!({"command_line":180_000});
let (directory, store, runtime) = setup(config).await;
let marker = directory.path().join("command-budget.jsonl");
store.snapshot.lock().unwrap().instances[0].configuration["command_marker"] = json!(marker);
let commands = runtime.prepare_command_line().await.unwrap();
let task = tokio::spawn(async move {
let result = commands.execute("commands", "inspect", &[]).await;
(commands, result)
});
tokio::time::timeout(Duration::from_secs(5), async {
while !marker.exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap();
tokio::time::pause();
tokio::time::advance(Duration::from_secs(121)).await;
tokio::task::yield_now().await;
assert!(!task.is_finished(), "声明的命令预算不受默认 120 秒截断");
tokio::time::advance(Duration::from_secs(60)).await;
let (commands, result) = task.await.unwrap();
tokio::time::resume();
let error = result.err().unwrap();
assert!(matches!(
error,
gateway_plugin_runtime::PluginCommandError::Incomplete {
saved_accounts: 0,
..
}
));
assert!(error.to_string().contains("超时") || error.to_string().contains("总期限"));
commands.shutdown().await;
assert_eq!(std::fs::read_to_string(marker).unwrap().lines().count(), 1);
}
@@ -41,6 +41,24 @@ async fn http_plugin_with_policy(
PluginRuntime,
ExtensionSetReference,
gateway_core::engine::middleware::FrozenMiddlewarePlan,
) {
http_plugin_with_configuration(
serde_json::json!({"http":true,"mode":mode}),
limits,
failure_policy,
)
.await
}
async fn http_plugin_with_configuration(
configuration: serde_json::Value,
limits: RpcLimits,
failure_policy: PluginFailurePolicy,
) -> (
tempfile::TempDir,
PluginRuntime,
ExtensionSetReference,
gateway_core::engine::middleware::FrozenMiddlewarePlan,
) {
let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap();
let package = crate::support::package_with_contributions(
@@ -55,7 +73,7 @@ async fn http_plugin_with_policy(
let (cache, runtime) = setup_package_with_limits(
vec![InstanceFixture {
id: "http-plugin",
configuration: serde_json::json!({"http":true,"mode":mode}),
configuration,
bindings: vec![binding(MIDDLEWARE_CONTRIBUTION, "http", 0, failure_policy)],
}],
package,
@@ -64,14 +64,11 @@ async fn session_model_and_network_callbacks_outlive_handshake_and_each_executio
let key = format!("key_{}", uuid::Uuid::new_v4().simple());
environment.client_key(&key, "sk-session-fixture").await;
environment
.install_plugin(serde_json::json!({"http":true,"mode":"callbacks"}))
.await;
let (runtime, bundle) = environment
.runtime_with_limits(RpcLimits {
maximum_call_timeout: Duration::from_millis(500),
..RpcLimits::default()
})
.install_plugin(
serde_json::json!({"http":true,"mode":"callbacks", "call_budgets_ms":{"http":500}}),
)
.await;
let (runtime, bundle) = environment.runtime_with_limits(RpcLimits::default()).await;
environment.store.start_command_line_writes().unwrap();
let snapshot = bundle.snapshots().snapshot_for_diagnostics().unwrap();
let plan = runtime
@@ -185,12 +182,10 @@ async fn session_model_and_network_callbacks_outlive_handshake_and_each_executio
#[tokio::test]
async fn real_plugin_controls_upgrade_and_concurrent_duplex_with_large_binary_frames() {
let (_cache, runtime, generation, plan) = http_plugin_with_limits(
"upgrade",
RpcLimits {
maximum_call_timeout: Duration::from_millis(500),
..RpcLimits::default()
},
let (_cache, runtime, generation, plan) = http_plugin_with_configuration(
serde_json::json!({"http":true,"mode":"upgrade", "call_budgets_ms":{"http":500}}),
RpcLimits::default(),
PluginFailurePolicy::Reject,
)
.await;
let (send, incoming) = tokio::sync::mpsc::channel(1);
@@ -147,7 +147,8 @@ async fn sdk_budget_runtime(stage: Stage, count: usize) -> (tempfile::TempDir, P
.enumerate()
.map(|(order, id)| InstanceFixture {
id,
configuration: serde_json::json!({"mode":"map", "attempt":stage == Stage::Attempt}),
configuration: serde_json::json!({"mode":"map", "attempt":stage == Stage::Attempt,
"call_budgets_ms":{"request":300,"attempt":300}}),
bindings: vec![binding(
MIDDLEWARE_CONTRIBUTION,
mount,
@@ -156,15 +157,7 @@ async fn sdk_budget_runtime(stage: Stage, count: usize) -> (tempfile::TempDir, P
)],
})
.collect();
setup_package_with_limits(
instances,
package,
RpcLimits {
maximum_call_timeout: Duration::from_millis(300),
..RpcLimits::default()
},
)
.await
setup_package_with_limits(instances, package, RpcLimits::default()).await
}
fn budget_request() -> MiddlewareRequest {
@@ -369,54 +369,85 @@ async fn retry_policy_preserves_decisions_and_leaves_replay_safety_to_core() {
}
#[tokio::test]
async fn retry_policy_timeout_exhausts_the_chain_budget_and_delegates() {
async fn retry_policy_combines_instance_budgets_with_one_request_bounded_chain() {
use gateway_core::{
engine::policy::{RetryDecision, RetryFacts},
error::ProviderErrorKind,
upstream::UpstreamSendState,
};
let records = tempfile::tempdir().unwrap();
let second_marker = records.path().join("second.jsonl");
let (_cache, runtime) = setup(
vec![
for (
first_budget,
first_delay,
second_budget,
second_delay,
request_budget,
expected,
reached_second,
) in [
(100, 500, 1000, 100, 2000, RetryDecision::Stop, true),
(1000, 100, 50, 300, 2000, RetryDecision::Delegate, true),
(1000, 300, 1000, 800, 2000, RetryDecision::Delegate, true),
(1000, 500, 1000, 0, 100, RetryDecision::Delegate, false),
(3000, 2100, 3000, 0, 5000, RetryDecision::Stop, true),
] {
let records = tempfile::tempdir().unwrap();
let second_marker = records.path().join("second.jsonl");
let (_cache, runtime) = setup(vec![
InstanceFixture {
id: "retry-delayed",
configuration: serde_json::json!({"retry_delay_ms":500,"retry_decision":{"decision":"stop"}}),
bindings: vec![binding("test.example.retryPolicy", "retry", 0, PluginFailurePolicy::Delegate)],
configuration: serde_json::json!({
"call_budgets_ms":{"retry":first_budget},
"retry_delay_ms":first_delay,
"retry_decision":{"decision":"delegate"},
}),
bindings: vec![binding(
"test.example.retryPolicy",
"retry",
0,
PluginFailurePolicy::Delegate,
)],
},
InstanceFixture {
id: "retry-next",
configuration: serde_json::json!({"retry_marker":second_marker,"retry_decision":{"decision":"stop"}}),
bindings: vec![binding("test.example.retryPolicy", "retry", 1, PluginFailurePolicy::Delegate)],
configuration: serde_json::json!({
"call_budgets_ms":{"retry":second_budget},
"retry_delay_ms":second_delay,
"retry_marker":second_marker,
"retry_decision":{"decision":"stop"},
}),
bindings: vec![binding(
"test.example.retryPolicy",
"retry",
1,
PluginFailurePolicy::Delegate,
)],
},
],
).await;
let context = policy_context(&runtime, prepare(&runtime).await, "req_retry_timeout");
let decision = tokio::time::timeout(
Duration::from_secs(2),
context.retry_decision(RetryFacts {
attempt_index: NonZeroU32::MIN,
provider: ProviderKind::new("openai").unwrap(),
model: Some("gpt-test".into()),
error_kind: ProviderErrorKind::RateLimited,
upstream_status: Some(429),
send_state: UpstreamSendState::NotSent,
remaining_routing_attempts: 1,
remaining_deadline: Duration::from_millis(100),
retry_allowed: true,
}),
)
.await
.expect("the shared deadline bounds the whole retry chain");
assert_eq!(decision, RetryDecision::Delegate);
assert!(
!second_marker.exists(),
"later policies cannot restart an exhausted budget"
);
runtime.shutdown().await;
])
.await;
let context = policy_context(&runtime, prepare(&runtime).await, "req_retry_timeout");
let decision = tokio::time::timeout(
Duration::from_secs(6),
context.retry_decision(RetryFacts {
attempt_index: NonZeroU32::MIN,
provider: ProviderKind::new("openai").unwrap(),
model: Some("gpt-test".into()),
error_kind: ProviderErrorKind::RateLimited,
upstream_status: Some(429),
send_state: UpstreamSendState::NotSent,
remaining_routing_attempts: 1,
remaining_deadline: Duration::from_millis(request_budget),
retry_allowed: true,
}),
)
.await
.expect("重试链不能重置总预算");
assert_eq!(
decision, expected,
"{first_budget}/{second_budget}/{request_budget}"
);
assert_eq!(second_marker.exists(), reached_second);
runtime.shutdown().await;
}
}
async fn marker_lines(path: &std::path::Path, count: usize) -> Vec<serde_json::Value> {
@@ -268,13 +268,18 @@ async fn outbound_next_wait_pauses_plugin_budget_and_cancel_releases_the_continu
for cancel in [false, true] {
let (cache, runtime) = runtime(
vec![
instance("outer", 0, serde_json::json!({})),
instance("inner", 1, serde_json::json!({})),
instance(
"outer",
0,
serde_json::json!({"call_budgets_ms":{"outbound":300}}),
),
instance(
"inner",
1,
serde_json::json!({"call_budgets_ms":{"outbound":300}}),
),
],
RpcLimits {
maximum_call_timeout: Duration::from_millis(300),
..RpcLimits::default()
},
RpcLimits::default(),
)
.await;
let generation = prepare(&runtime).await;
@@ -136,6 +136,7 @@ async fn retry_callback_effects_survive_success_failure_and_cancellation() {
"retry_http_return_marker":(outcome == "early_result").then_some(marker),
"retry_service_request":is_service.then_some(service_request),
"retry_service_marker":service_marker,
"call_budgets_ms":{"retry":500},
}))
.await;
let key = "sk-retry-effects-fixture";
@@ -146,10 +147,7 @@ async fn retry_callback_effects_survive_success_failure_and_cancellation() {
});
let (runtime, core) = environment
.runtime_with_providers(
RpcLimits {
maximum_call_timeout: Duration::from_millis(500),
..RpcLimits::default()
},
RpcLimits::default(),
ProviderRegistry::new([provider.clone() as Arc<dyn Provider>]).unwrap(),
)
.await;
@@ -552,3 +552,55 @@ async fn a_host_version_mismatch_still_restores_the_enabled_plugin() {
runtime.shutdown().await;
super::super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn declared_registration_budget_is_not_cut_short_by_process_preparation_timeout() {
let (cache, store, runtime) = super::super::setup().await;
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration = serde_json::json!({
"registration_delay_ms":31_000,
"call_budgets_ms":{"registration":40_000},
});
let candidate = tokio::time::timeout(
Duration::from_secs(45),
PluginPreparation::prepare(&runtime, snapshot),
)
.await
.expect("registration stays bounded by its declared budget")
.expect("ready process may register beyond the startup control deadline");
assert!(candidate.is_ready());
drop(candidate);
super::super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn shutdown_cancels_long_registration_and_reclaims_the_unpublished_candidate() {
let (cache, store, runtime) = super::super::setup().await;
let runtime = std::sync::Arc::new(runtime);
let markers = tempfile::tempdir().unwrap();
let started = markers.path().join("registration");
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration = serde_json::json!({
"registration_started_marker":started,
"registration_delay_ms":600_000,
"call_budgets_ms":{"registration":600_000},
});
let preparing = tokio::spawn({
let runtime = std::sync::Arc::clone(&runtime);
async move { PluginPreparation::prepare(runtime.as_ref(), snapshot).await }
});
tokio::time::timeout(Duration::from_secs(5), async {
while !started.exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(3), runtime.shutdown())
.await
.expect(
"shutdown must cancel candidate preparation instead of waiting for its long budget",
);
assert!(preparing.await.unwrap().is_err());
super::super::wait_until_empty(cache.path()).await;
}
@@ -0,0 +1,157 @@
//! 验证维护任务跨发布保活、通知合并及实例替换后的取消顺序
use gateway_admin::{
model::{Revision, plugins::instances::PluginInstanceSnapshot},
ports::plugins::PluginPreparation,
};
use gateway_core::{
lifecycle::CancellationToken,
routing::{ConfigRevision, RuntimeSnapshot},
runtime::RuntimeSnapshotHandle,
task::{WorkerContribution, WorkerDaemonContext, WorkerRunnable},
};
use gateway_plugin_runtime::PluginRuntime;
use gateway_plugin_sdk::{Capability, Contributions, Stage};
use serde_json::{Value, json};
use std::{path::Path, sync::Arc, time::Duration};
async fn publish(
runtime: &PluginRuntime,
snapshots: &RuntimeSnapshotHandle,
instances: PluginInstanceSnapshot,
) {
let revision = ConfigRevision::new(instances.config_revision.get()).unwrap();
let prepared = PluginPreparation::prepare(runtime, instances)
.await
.unwrap();
snapshots.publish(
RuntimeSnapshot::new(
revision,
gateway_core::settings::SettingsValues::new(1, "smart", Default::default(), None, None),
vec![],
vec![],
vec![],
)
.unwrap()
.with_extensions(Some(prepared)),
);
}
fn records(path: &Path) -> Vec<Value> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
async fn wait_records(path: &Path, count: usize) -> Vec<Value> {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let entries = records(path);
if entries.len() >= count {
return entries;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap_or_else(|error| panic!("missing maintenance records: {error}; {:?}", records(path)))
}
#[tokio::test]
async fn unrelated_publications_preserve_inflight_maintenance_and_replacement_waits_for_cancel() {
let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([
crate::support::contribution(
Capability::Maintenance,
vec![Stage::Maintenance],
vec![],
vec![],
),
]))
.await;
let runtime = Arc::new(runtime);
let markers = tempfile::tempdir().unwrap();
let mut instances = store.snapshot.lock().unwrap().clone();
let template = instances.instances[0].clone();
instances.instances = ["a", "b"]
.into_iter()
.map(|id| {
let mut instance = template.clone();
instance.id = id.into();
instance.configuration = json!({
"maintenance_marker":markers.path().join(id),
"execution_cancelled_marker":markers.path().join(id),
"maintenance_wait_marker":markers.path().join(format!("release-{id}")),
"call_context_marker":markers.path().join(format!("context-{id}")),
"call_budgets_ms":{"maintenance":600_000},
});
instance
})
.collect();
let snapshots = RuntimeSnapshotHandle::default();
publish(&runtime, &snapshots, instances.clone()).await;
let WorkerContribution::Registration(registration) =
runtime.maintenance_worker(snapshots.clone()).unwrap()
else {
panic!("maintenance worker registration")
};
let WorkerRunnable::Daemon { task, .. } = registration.runnable else {
panic!("maintenance daemon")
};
let stop = CancellationToken::new();
let running = tokio::spawn({
let stop = stop.clone();
async move { task.run(WorkerDaemonContext::new(stop)).await.unwrap() }
});
let a = markers.path().join("a");
let b = markers.path().join("b");
assert_eq!(wait_records(&a, 1).await[0]["phase"], "start");
assert_eq!(wait_records(&b, 1).await[0]["phase"], "start");
let contexts = records(&markers.path().join("context-a"));
let maintenance = contexts
.iter()
.find(|entry| entry["method"] == "plugin.reconcile")
.expect("maintenance call context");
assert_eq!(maintenance["context"]["timeout_ms"], 600_000);
instances.config_revision = Revision::new(2).unwrap();
instances.instances[1].revision = instances.config_revision;
publish(&runtime, &snapshots, instances.clone()).await;
let changed = wait_records(&b, 3).await;
assert!(
changed[1].get("call_id").is_some(),
"旧维护必须先完成取消确认"
);
assert_eq!(changed[2]["phase"], "start");
assert_eq!(records(&a).len(), 1, "无关实例发布不得重启 A 的在途维护");
instances.config_revision = Revision::new(3).unwrap();
instances.instances[1].revision = instances.config_revision;
instances.instances[1].enabled = false;
publish(&runtime, &snapshots, instances.clone()).await;
assert!(wait_records(&b, 4).await[3].get("call_id").is_some());
instances.config_revision = Revision::new(4).unwrap();
instances.instances[1].revision = instances.config_revision;
instances.instances[1].enabled = true;
publish(&runtime, &snapshots, instances.clone()).await;
assert_eq!(wait_records(&b, 5).await[4]["phase"], "start");
assert_eq!(records(&a).len(), 1, "停用与恢复 B 不得取消 A");
// 多次发布通知合并为一次后续对账,首轮仍使用原调用 ID 完成
std::fs::write(markers.path().join("release-a"), []).unwrap();
let completed = wait_records(&a, 4).await;
assert_eq!(completed[1]["phase"], "done");
assert_eq!(completed[1]["id"], completed[0]["id"]);
assert_eq!(completed[2]["phase"], "start");
assert_eq!(completed[3]["phase"], "done");
assert_ne!(completed[2]["id"], completed[0]["id"]);
assert_eq!(completed.len(), 4);
stop.cancel();
running.await.unwrap();
assert!(wait_records(&b, 6).await[5].get("call_id").is_some());
snapshots.suspend();
runtime.shutdown().await;
super::super::wait_until_empty(cache.path()).await;
}
@@ -2,6 +2,7 @@
mod diagnostics;
mod instance;
mod maintenance;
mod management;
mod runtime;
mod set;
@@ -533,14 +533,8 @@ async fn adapter_sse_outlives_rpc_budget_and_cancellation_closes_its_resources()
.as_object_mut()
.unwrap()
.remove("wire");
let (cache, _, runtime) = setup_with_limits(
config,
gateway_plugin_runtime::RpcLimits {
maximum_call_timeout: Duration::from_millis(500),
..Default::default()
},
)
.await;
config["call_budgets_ms"] = json!({"upstream":500});
let (cache, _, runtime) = setup(config).await;
let generation =
ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
@@ -36,6 +36,33 @@ pub(super) struct LifecycleCallbacks {
struct OversizedReplyCallbacks;
#[derive(Default)]
struct BudgetCallbacks {
started: Arc<Notify>,
release: Arc<Notify>,
}
impl CallbackHandler for BudgetCallbacks {
fn call(
&self,
context: CallContext,
_method: String,
_params: serde_json::Value,
payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
let started = Arc::clone(&self.started);
let release = Arc::clone(&self.release);
Box::pin(async move {
started.notify_one();
release.notified().await;
Ok(RpcReply {
result: json!({"budget_ms": context.timeout_ms}),
payload,
})
})
}
}
impl CallbackHandler for OversizedReplyCallbacks {
fn call(
&self,
@@ -336,7 +363,7 @@ async fn real_process_round_trips_raw_bytes_and_reentrant_callbacks() {
session
.context(Stage::Registration, Duration::MAX)
.timeout_ms,
u64::try_from(RpcLimits::default().maximum_call_timeout.as_millis()).unwrap()
5_000
);
*callbacks.nested.lock().unwrap() = Some(Arc::downgrade(&session));
let context = session.context(Stage::Management, Duration::from_secs(2));
@@ -355,6 +382,106 @@ async fn real_process_round_trips_raw_bytes_and_reentrant_callbacks() {
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn declared_stage_budget_covers_long_callback_without_affecting_other_calls() {
let (_cache, prepared, mut handshake, processes) = prepared_session();
handshake.configuration = json!({"call_budgets_ms":{"maintenance":600_000,"management":40}});
let callbacks = Arc::new(BudgetCallbacks::default());
let session = Arc::new(
RpcSession::start(
prepared,
handshake,
RpcLimits::default(),
&processes,
callbacks.clone(),
)
.await
.unwrap(),
);
let calling = {
let session = Arc::clone(&session);
tokio::spawn(async move {
session
.call(
"callback",
session.context(Stage::Maintenance, session.budget(Stage::Maintenance)),
json!({}),
vec![],
)
.await
})
};
callbacks.started.notified().await;
tokio::time::pause();
tokio::time::advance(Duration::from_secs(125)).await;
assert!(!calling.is_finished());
tokio::time::resume();
let short = session
.call(
"hang",
session.context(Stage::Management, session.budget(Stage::Management)),
json!({}),
vec![],
)
.await;
assert!(matches!(short, Err(RpcError::Timeout)));
callbacks.release.notify_one();
let reply = calling.await.unwrap().unwrap();
assert_eq!(reply.result, json!({"budget_ms":600_000}));
assert!(session.is_ready());
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn invalid_budget_rejects_candidate_before_business_and_releases_process_capacity() {
let (_cache, prepared, mut handshake, _) = prepared_session();
let processes =
gateway_host::process::ProcessSupervisor::new(std::num::NonZeroUsize::new(1).unwrap());
handshake.configuration = json!({"call_budgets_ms":{"maintenance":0}});
let callbacks = Arc::new(LifecycleCallbacks::default());
let result = RpcSession::start(
prepared.clone(),
handshake.clone(),
RpcLimits::default(),
&processes,
callbacks.clone(),
)
.await;
assert!(matches!(
result,
Err(RpcError::Budget(
gateway_plugin_sdk::CallBudgetError::Invalid {
stage: Stage::Maintenance,
milliseconds: 0,
}
))
));
assert_eq!(callbacks.begun.load(Ordering::Relaxed), 0);
handshake.configuration = json!({});
handshake.incarnation = uuid::Uuid::new_v4().to_string();
let session = RpcSession::start(
prepared,
handshake,
RpcLimits::default(),
&processes,
callbacks,
)
.await
.unwrap();
assert!(
session
.call(
"echo",
session.context(Stage::Maintenance, session.budget(Stage::Maintenance)),
json!({}),
vec![]
)
.await
.is_ok()
);
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn slow_management_call_does_not_block_an_independent_call() {
let (_cache, session) = session(Arc::new(Callbacks::default())).await;
@@ -11,7 +11,7 @@ use gateway_plugin_sdk::{
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let session = PluginSession::accept(
let mut session = PluginSession::accept(
tokio::io::stdin(),
tokio::io::stdout(),
SessionConfig::default(),
@@ -22,6 +22,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap_or("map")
.to_owned();
let configuration = session.handshake().configuration.clone();
if let Some(budgets) = configuration.get("call_budgets_ms") {
session.set_call_budgets(serde_json::from_value(budgets.clone())?)?;
}
if mode == "mixed_mounts" {
let contributes = Contributions::from([(
Capability::Middleware,
@@ -528,15 +528,29 @@ impl Peer {
return;
}
"plugin.reconcile" => {
let result = if let Some(results) = self.data_queries(id).await {
self.append_observation_marker(
"maintenance_marker",
&json!({"phase":"done", "results":results}),
);
Ok(())
} else {
self.reconcile_fixture(id).await
};
let result =
if let Some(path) = self.configuration["maintenance_wait_marker"].as_str() {
self.append_observation_marker(
"maintenance_marker",
&json!({"phase":"start", "id":id}),
);
while !std::path::Path::new(path).exists() {
tokio::time::sleep(Duration::from_millis(20)).await;
}
self.append_observation_marker(
"maintenance_marker",
&json!({"phase":"done", "id":id}),
);
Ok(())
} else if let Some(results) = self.data_queries(id).await {
self.append_observation_marker(
"maintenance_marker",
&json!({"phase":"done", "results":results}),
);
Ok(())
} else {
self.reconcile_fixture(id).await
};
match result {
Ok(()) => {
self.send(
@@ -1374,6 +1388,10 @@ impl Peer {
return;
}
"plugin.register" => {
self.append_observation_marker("registration_started_marker", &json!({"id":id}));
if let Some(delay) = self.configuration["registration_delay_ms"].as_u64() {
tokio::time::sleep(Duration::from_millis(delay)).await;
}
if let Some(request) = self.configuration.get("registration_http") {
let result = self
.callback_payload(
@@ -1489,6 +1507,13 @@ async fn main() {
Message::Ready {
protocol_version: gateway_plugin_sdk::PROTOCOL_VERSION,
incarnation: handshake.incarnation,
call_budgets: serde_json::from_value(
peer.configuration
.get("call_budgets_ms")
.cloned()
.unwrap_or_else(|| json!({})),
)
.unwrap(),
},
vec![],
)
@@ -1519,6 +1544,10 @@ async fn main() {
} => {
assert!(id > last_call && !id.is_multiple_of(2) && context.call_id == id);
last_call = id;
peer.append_observation_marker(
"call_context_marker",
&json!({"method":method, "context":context}),
);
if matches!(
method.as_str(),
"malformed_truncated_frame" | "malformed_frame_length" | "malformed_metadata"
+50 -3
View File
@@ -64,7 +64,7 @@ sequenceDiagram
participant H as 宿主 Runtime
participant P as 插件会话
H->>P: Hello
P-->>H: Ready(相同 incarnation)
P-->>H: Ready(相同 incarnation、所需预算声明)
H->>P: 注册能力,核对清单
P-->>H: 注册结果
H->>P: Call(ID、上下文、载荷)
@@ -78,7 +78,7 @@ sequenceDiagram
### 并发与期限
每个宿主插件会话默认允许 64 个执行中的 RPC 调用、64 个初次排队调用和 256 个总在途上下文。
初次排队和等待 `next` 后的恢复排队各限 30 秒,不扣插件自身默认上限 120 秒的处理预算。
初次排队和等待 `next` 后的恢复排队各限 30 秒,不扣插件自身的处理预算。
恢复等待占总在途名额,不占初次排队名额。
等待 `next` 时释放执行名额并暂停处理预算,返回后重新取得名额,继续同一次调用;
已返回响应头的流继续占用在途预算,不占首结果执行名额。具体组合行为见[洋葱中间件](docs/capabilities.md#洋葱中间件)
@@ -91,13 +91,60 @@ sequenceDiagram
达到容量或等待上限时,中间件返回过载错误;HTTP 响应为 503(`middleware_capacity_unavailable`),
不跳过插件或重放请求。调整 SDK 会话配置需重新构建插件包,容量应结合插件耗时、正文和响应流的资源占用评估
### 声明调用预算
插件在 `PluginSession::accept` 后、`run` 前通过 `set_call_budgets` 声明各阶段所需的毫秒预算,可读取
`session.handshake().configuration`,由自身配置决定模型、批量任务等场景的时长
```rust,no_run
use gateway_plugin_sdk::{CallBudgets, Stage};
use gateway_plugin_sdk::client::{PluginHandler, PluginSession, SessionConfig, SessionError};
# async fn configure<R, W, H>(reader: R, writer: W, handler: H) -> Result<(), SessionError>
# where R: tokio::io::AsyncRead + Unpin + Send + 'static,
# W: tokio::io::AsyncWrite + Unpin + Send + 'static, H: PluginHandler {
let mut session = PluginSession::accept(reader, writer, SessionConfig::default()).await?;
let maintenance_ms = session.handshake().configuration["maintenanceBudgetMs"]
.as_u64()
.unwrap_or(600_000);
session.set_call_budgets(CallBudgets::from([
(Stage::Maintenance, maintenance_ms),
(Stage::CommandLine, 1_800_000),
]))?;
session.run(handler).await?;
# Ok(())
# }
```
未声明的阶段使用协议默认值,默认表由 SDK 集中维护,宿主与插件使用同一份合同。
默认值不限制插件显式声明的最大时长
| 阶段 | 默认预算 |
| --- | ---: |
| `registration` | 5 秒 |
| `configuration`、`authentication` | 30 秒 |
| `routing`、`scheduling`、`retry`、`observation` | 2 秒 |
| `maintenance`、`management`、`public_management`、`command_line` | 120 秒 |
| `http`、`service`、`websocket`、`request`、`attempt`、`outbound`、`upstream` | 120 秒 |
`Ready.call_budgets` 携带显式声明,双方按相同规则解析并冻结该会话预算。预算必须为正的有限毫秒值,
不接受零值、数值溢出和平台无法表示的期限。修改声明需要创建新会话,不支持调用中无限续期
每次 `CallContext.timeout_ms` 是该调用的初始处理预算,外部剩余期限较短时可以小于会话预算。
`call.host.remaining_budget()` 读取共享期限中的剩余值,正确反映 `next` 暂停;资源流 owner 接管寿命后返回 `None`。
普通模型和网络回调消耗父调用剩余预算,不会重置预算。请求总期限、网络操作期限、停用和取消仍独立生效,
声明长预算不保证模型必然完成,也不提供跨宿主重启恢复;检查点和业务幂等由插件维护
宿主关闭使用独立宽限并取消未完成调用,不等待插件处理预算耗尽。
注册与配置迁移位于实例准备链路,其实际耗时仍会影响宿主启动和实例就绪
### 帧与会话
`CallContext.timeout_ms` 限定初始处理期限。宿主对由流或连接 owner 管理的调用设置 `resource_stream`:
首个流式结果成功返回后,正文、会话及关联回调由资源取消与终态回收,不再沿用初始期限。
普通调用继续使用原期限;资源模式仍保留并发容量、Credit、父调用关联及取消约束
协议 v5 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
协议 v6 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块,
不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付
@@ -138,8 +138,8 @@ Provider 不支持刷新(如 OpenAI URL + Key 账号)返回 `invalid_input`
### 自有资源与维护
声明 `maintenance` 并注册 `methods::RECONCILE`,宿主会在实例启用发布、进程恢复及配置变更后调用
`plugin.reconcile`,并每 30 秒补偿一次。处理器必须幂等;调用可能重复,配置通知会合并,不表示逐条账号事件。
每个实例串行执行,不同实例相互独立;调用限时 30 秒,返回值不消费,调用失败后等待 5 秒重试。维护失败不回滚已经提交的资源,
`plugin.reconcile`,每轮完成后等待 30 秒补偿。处理器必须幂等;调用可能重复,配置通知会合并,不表示逐条账号事件。
每个实例串行执行,不同实例相互独立;处理预算按[会话声明](../README.md#声明调用预算)生效,返回值不消费,调用失败后等待 5 秒重试。维护失败不回滚已经提交的资源,
下一次对账继续补齐。只读校验、准备候选与 CLI 帮助不会启动维护;停用、替换和宿主关闭时取消旧任务
维护与其他处理器共用以下资源方法:
@@ -26,7 +26,7 @@
| 版本字段 | 当前值 | 对应合同 |
| --- | --- | --- |
| `manifestVersion` / `manifest_schema_versions` | `2` | 插件清单格式 |
| `package.protocolVersion` / `protocol_versions` | `5` | 插件进程 RPC 封装,不是 OpenAI 或 xAI 的业务协议 |
| `package.protocolVersion` / `protocol_versions` | `6` | 插件进程 RPC 封装,不是 OpenAI 或 xAI 的业务协议 |
| `contributes.<capability>.version` / `capabilities[].versions` | 见[扩展项简写](#扩展项简写) | 指定扩展能力的行为合同 |
| 宿主声明的 `schema_version` | `2` | 宿主兼容声明自身的格式 |
@@ -34,7 +34,7 @@
清单不接受 `permissions` 字段;插件按[完整信任](#完整信任)运行
RPC v5 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
RPC v6 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
安装包清单校验与已安装元数据读取是两个边界:清单拒绝未知字段,并检查必需字段、类型和版本;
数据库元数据的读取规则见[制品与发布](../../../../../docs/architecture.md#制品配置与发布),不改变清单或 RPC 合同。
@@ -0,0 +1,119 @@
//! 按调用阶段解析有限处理预算,集中维护协议默认值与插件声明
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use crate::Stage;
/// 按阶段声明的毫秒预算;未声明的阶段采用协议默认值
pub type CallBudgets = BTreeMap<Stage, u64>;
const STAGES: [Stage; 18] = [
Stage::Registration,
Stage::Configuration,
Stage::Authentication,
Stage::Routing,
Stage::Scheduling,
Stage::Retry,
Stage::Http,
Stage::Service,
Stage::WebSocket,
Stage::Request,
Stage::Attempt,
Stage::Outbound,
Stage::Upstream,
Stage::Observation,
Stage::Management,
Stage::CommandLine,
Stage::PublicManagement,
Stage::Maintenance,
];
/// 实际预算来自协议默认值或插件显式声明
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallBudgetSource {
Default,
Plugin,
}
/// 已校验且不可变的会话预算,不允许构造缺少阶段的解析结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedCallBudgets(BTreeMap<Stage, (Duration, CallBudgetSource)>);
impl ResolvedCallBudgets {
/// 按协议默认值合并插件声明并冻结所有阶段的有效预算
///
/// # Errors
///
/// 声明值为零或平台计时器无法表示时失败
pub fn resolve(declarations: &CallBudgets) -> Result<Self, CallBudgetError> {
validate_values(declarations)?;
let resolved = STAGES
.into_iter()
.map(|stage| {
let (milliseconds, source) = match declarations.get(&stage) {
Some(&requested) => (requested, CallBudgetSource::Plugin),
None => (default_budget_ms(stage), CallBudgetSource::Default),
};
(stage, (Duration::from_millis(milliseconds), source))
})
.collect();
Ok(Self(resolved))
}
/// 读取当前会话在指定阶段的处理预算
#[must_use]
pub fn budget(&self, stage: Stage) -> Duration {
self.0[&stage].0
}
/// 读取预算来源,用于调用诊断
#[must_use]
pub fn source(&self, stage: Stage) -> CallBudgetSource {
self.0[&stage].1
}
}
/// 预算声明错误只暴露阶段和数值,不包含插件配置或业务数据
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CallBudgetError {
#[error("plugin call budget for {stage:?} is invalid: {milliseconds} ms")]
Invalid { stage: Stage, milliseconds: u64 },
}
pub(crate) fn validate_values(values: &CallBudgets) -> Result<(), CallBudgetError> {
for (&stage, &milliseconds) in values {
if milliseconds == 0
|| Instant::now()
.checked_add(Duration::from_millis(milliseconds))
.is_none()
{
return Err(CallBudgetError::Invalid {
stage,
milliseconds,
});
}
}
Ok(())
}
const fn default_budget_ms(stage: Stage) -> u64 {
match stage {
Stage::Registration => 5_000,
Stage::Configuration | Stage::Authentication => 30_000,
Stage::Routing | Stage::Scheduling | Stage::Retry | Stage::Observation => 2_000,
Stage::Http
| Stage::Service
| Stage::WebSocket
| Stage::Request
| Stage::Attempt
| Stage::Outbound
| Stage::Upstream
| Stage::Management
| Stage::CommandLine
| Stage::PublicManagement
| Stage::Maintenance => 120_000,
}
}
@@ -17,7 +17,6 @@ pub struct SessionConfig {
pub maximum_callbacks: usize,
pub maximum_buffered_stream_chunks: usize,
pub handshake_timeout: Duration,
pub maximum_call_timeout: Duration,
}
impl Default for SessionConfig {
@@ -29,7 +28,6 @@ impl Default for SessionConfig {
maximum_callbacks: 64,
maximum_buffered_stream_chunks: 1_024,
handshake_timeout: Duration::from_secs(5),
maximum_call_timeout: Duration::from_secs(120),
}
}
}
@@ -45,7 +43,6 @@ impl SessionConfig {
|| self.maximum_buffered_stream_chunks == 0
|| self.maximum_buffered_stream_chunks > MAXIMUM_BUFFERED_STREAM_CHUNKS
|| self.handshake_timeout.is_zero()
|| self.maximum_call_timeout.is_zero()
{
return Err(SessionError::Configuration);
}
@@ -11,7 +11,8 @@ use tokio::{
};
use crate::{
CallContext, ErrorCode, Frame, FrameError, Handshake, Message, PROTOCOL_VERSION, PluginFault,
CallBudgets, CallContext, ErrorCode, Frame, FrameError, Handshake, Message, PROTOCOL_VERSION,
PluginFault, ResolvedCallBudgets,
};
use super::super::frame::{EncodedFrame, read_frame, write_frame};
@@ -31,6 +32,7 @@ pub struct PluginSession<R, W> {
writer: W,
handshake: Handshake,
config: SessionConfig,
call_budgets: CallBudgets,
}
impl<R, W> PluginSession<R, W>
@@ -70,6 +72,7 @@ where
writer,
handshake,
config,
call_budgets: CallBudgets::new(),
})
}
@@ -78,6 +81,18 @@ where
&self.handshake
}
/// 在 Ready 前声明所需预算,可根据握手中的实例配置计算
/// 未声明的阶段采用协议默认值,同一会话的预算在 run 时冻结
///
/// # Errors
///
/// 预算为零或平台计时器无法表示时失败,原声明保持不变
pub fn set_call_budgets(&mut self, budgets: CallBudgets) -> Result<(), SessionError> {
crate::budget::validate_values(&budgets)?;
self.call_budgets = budgets;
Ok(())
}
/// 发送 Ready 并持续处理调用、回调、流控、取消与关闭
///
/// # Errors
@@ -87,6 +102,7 @@ where
where
H: PluginHandler,
{
let call_budgets = ResolvedCallBudgets::resolve(&self.call_budgets)?;
let data_capacity = self
.config
.maximum_calls
@@ -117,6 +133,7 @@ where
&Frame::control(Message::Ready {
protocol_version: PROTOCOL_VERSION,
incarnation: self.handshake.incarnation.clone(),
call_budgets: self.call_budgets,
}),
)
.await?;
@@ -124,6 +141,7 @@ where
let (completed, completions) = mpsc::channel(self.config.maximum_calls);
let mut driver = SessionDriver {
config: self.config,
call_budgets,
instance_id: self.handshake.instance_id,
generation: self.handshake.generation,
incarnation: self.handshake.incarnation,
@@ -200,6 +218,7 @@ impl Drop for CompletionGuard {
struct SessionDriver {
config: SessionConfig,
call_budgets: ResolvedCallBudgets,
instance_id: String,
generation: u64,
incarnation: String,
@@ -323,7 +342,7 @@ impl SessionDriver {
|| context.generation != self.generation
|| context.incarnation != self.incarnation
|| context.timeout_ms == 0
|| Duration::from_millis(context.timeout_ms) > self.config.maximum_call_timeout
|| Duration::from_millis(context.timeout_ms) > self.call_budgets.budget(context.stage)
|| method.is_empty()
|| method.len() > 128
{
@@ -1,12 +1,14 @@
//! 插件侧传输和会话错误及稳定业务错误投影
use crate::{ErrorCode, FrameError, PluginFault};
use crate::{CallBudgetError, ErrorCode, FrameError, PluginFault};
/// 插件侧传输和会话错误
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("plugin session configuration is invalid")]
Configuration,
#[error(transparent)]
CallBudget(#[from] CallBudgetError),
#[error("plugin handshake is invalid")]
Handshake,
#[error("plugin protocol is invalid")]
@@ -36,7 +38,11 @@ impl SessionError {
Self::Timeout => PluginFault::new(ErrorCode::Timeout, "host callback timed out"),
Self::Cancelled => PluginFault::new(ErrorCode::Cancelled, "parent call was cancelled"),
Self::Capacity => PluginFault::new(ErrorCode::Capacity, "plugin capacity is exhausted"),
Self::Configuration | Self::Protocol | Self::Handshake | Self::Frame(_) => {
Self::Configuration
| Self::CallBudget(_)
| Self::Protocol
| Self::Handshake
| Self::Frame(_) => {
PluginFault::new(ErrorCode::InvalidInput, "plugin session input is invalid")
}
Self::Closed | Self::HandlerStopped => {
@@ -35,6 +35,13 @@ pub struct HostClient {
}
impl HostClient {
/// 读取父调用剩余处理预算,包含 next 暂停;资源流接管寿命后返回 None
/// 外部请求期限、网络操作期限和取消信号仍独立生效
#[must_use]
pub fn remaining_budget(&self) -> Option<std::time::Duration> {
self.deadline.remaining()
}
pub(crate) fn maximum_stream_chunk_bytes(&self) -> usize {
let window = self.credits.maximum_bytes();
if window == 0 {
@@ -1,5 +1,6 @@
//! 实验性插件线协议;不依赖网关领域或基础设施类型
mod budget;
pub mod call;
mod capability;
#[cfg(feature = "io")]
@@ -9,6 +10,7 @@ mod error;
mod manifest;
mod message;
pub use budget::{CallBudgetError, CallBudgetSource, CallBudgets, ResolvedCallBudgets};
pub use capability::{Capability, ContributionDeclaration, Contributions, FailurePolicy, Stage};
pub use context::{CallContext, Handshake, RequestContext};
pub use error::{ErrorCode, PluginFault, SendState};
@@ -2,9 +2,9 @@
use serde::{Deserialize, Serialize};
use crate::{CallContext, Handshake, PluginFault};
use crate::{CallBudgets, CallContext, Handshake, PluginFault};
pub const PROTOCOL_VERSION: u32 = 5;
pub const PROTOCOL_VERSION: u32 = 6;
/// 元数据与二进制载荷分开,流分块不经过 JSON/base64
#[derive(Clone, PartialEq, Serialize, Deserialize)]
@@ -16,6 +16,7 @@ pub enum Message {
Ready {
protocol_version: u32,
incarnation: String,
call_budgets: CallBudgets,
},
Call {
id: u64,
@@ -0,0 +1,49 @@
//! 验证场景默认预算、插件声明隔离与无效输入边界
use std::time::Duration;
use gateway_plugin_sdk::{
CallBudgetError, CallBudgetSource, CallBudgets, ResolvedCallBudgets, Stage,
};
#[test]
fn explicit_budgets_override_only_their_stage_without_a_global_ceiling() {
let resolved = ResolvedCallBudgets::resolve(&CallBudgets::from([
(Stage::Maintenance, 1_800_000),
(Stage::CommandLine, 600_000),
(Stage::Management, 250),
]))
.unwrap();
for (stage, millis, source) in [
(Stage::Maintenance, 1_800_000, CallBudgetSource::Plugin),
(Stage::CommandLine, 600_000, CallBudgetSource::Plugin),
(Stage::Management, 250, CallBudgetSource::Plugin),
(Stage::Registration, 5_000, CallBudgetSource::Default),
(Stage::Authentication, 30_000, CallBudgetSource::Default),
(Stage::Retry, 2_000, CallBudgetSource::Default),
(Stage::Request, 120_000, CallBudgetSource::Default),
] {
assert_eq!(resolved.budget(stage), Duration::from_millis(millis));
assert_eq!(resolved.source(stage), source);
}
assert_eq!(
ResolvedCallBudgets::resolve(&CallBudgets::new())
.unwrap()
.budget(Stage::Maintenance),
Duration::from_secs(120)
);
}
#[test]
fn invalid_budget_values_fail_before_use() {
assert!(matches!(
ResolvedCallBudgets::resolve(&CallBudgets::from([(Stage::Maintenance, 0)])),
Err(CallBudgetError::Invalid {
stage: Stage::Maintenance,
milliseconds: 0
})
));
assert!(
serde_json::from_str::<CallBudgets>(r#"{"maintenance":18446744073709551616}"#).is_err()
);
}
@@ -440,7 +440,6 @@ async fn start_session<H: PluginHandler + 'static>(
maximum_callbacks: 4,
maximum_buffered_stream_chunks: 16,
handshake_timeout: Duration::from_secs(1),
maximum_call_timeout: Duration::from_secs(2),
},
)
.await?
@@ -473,6 +472,7 @@ async fn start_session<H: PluginHandler + 'static>(
message: Message::Ready {
protocol_version: PROTOCOL_VERSION,
ref incarnation,
..
},
ref payload,
} if incarnation == "test-incarnation" && payload.is_empty()
@@ -1,6 +1,86 @@
//! 会话调用分派、并发帧读取与处理器响应边界测试
use super::*;
use gateway_plugin_sdk::CallBudgets;
#[tokio::test(start_paused = true)]
async fn declared_long_budgets_survive_callbacks_and_expose_remaining_time() {
for budget_ms in [600_000, 1_800_000] {
let (host, plugin) = tokio::io::duplex(16_384);
let (plugin_reader, plugin_writer) = tokio::io::split(plugin);
let task = tokio::spawn(async move {
let mut session =
PluginSession::accept(plugin_reader, plugin_writer, SessionConfig::default())
.await?;
let declared = session.handshake().configuration["maintenance_ms"]
.as_u64()
.unwrap();
session.set_call_budgets(CallBudgets::from([(Stage::Maintenance, declared)]))?;
session.run(TestHandler::default()).await
});
let (reader, writer) = tokio::io::split(host);
let mut host = HostPeer { reader, writer };
let mut hello = handshake();
hello.configuration = json!({"maintenance_ms": budget_ms});
send_control(&mut host, Message::Hello { handshake: hello }).await;
let Message::Ready { call_budgets, .. } = receive(&mut host).await.message else {
panic!("expected Ready")
};
assert_eq!(call_budgets[&Stage::Maintenance], budget_ms);
let mut call_context = context(1, Duration::from_millis(budget_ms));
call_context.stage = Stage::Maintenance;
send_control(
&mut host,
Message::Call {
id: 1,
method: "budget".into(),
context: call_context,
params: json!({}),
},
)
.await;
let Message::Callback {
id, parent_id: 1, ..
} = receive(&mut host).await.message
else {
panic!("expected callback")
};
tokio::time::advance(Duration::from_secs(125)).await;
send_control(
&mut host,
Message::Result {
id,
result: json!({}),
},
)
.await;
let Message::Result { id: 1, result } = receive(&mut host).await.message else {
panic!("long call must remain active")
};
assert_eq!(result["initial_ms"], budget_ms);
let remaining = result["remaining_ms"].as_u64().unwrap();
assert!(remaining <= budget_ms - 125_000 && remaining >= budget_ms - 126_000);
shutdown(&mut host, task).await;
}
}
#[tokio::test]
async fn calls_cannot_exceed_the_budget_negotiated_for_their_stage() {
let (mut host, task) = start_session(TestHandler::default()).await;
let mut context = context(1, Duration::from_secs(3));
context.stage = Stage::Retry;
send_control(
&mut host,
Message::Call {
id: 1,
method: "echo".into(),
context,
params: json!({}),
},
)
.await;
assert!(matches!(task.await.unwrap(), Err(SessionError::Protocol)));
}
#[test]
fn middleware_plugin_rejects_declarations_without_matching_handlers() {
@@ -58,6 +58,19 @@ impl PluginHandler for TestHandler {
Box::pin(async move {
match call.method.as_str() {
"echo" => Ok(CallReply::unary(call.params, call.payload)),
"budget" => {
call.host
.call("host.log", json!({}), vec![])
.await
.map_err(SessionError::into_plugin_fault)?;
Ok(CallReply::unary(
json!({
"initial_ms": call.context.timeout_ms,
"remaining_ms": call.host.remaining_budget().map(|value| value.as_millis()),
}),
vec![],
))
}
"key_facts" => {
let result = call
.host
@@ -263,7 +276,6 @@ async fn start_session_with_capacity<H: PluginHandler>(
maximum_callbacks: 4,
maximum_buffered_stream_chunks: 16,
handshake_timeout: Duration::from_secs(1),
maximum_call_timeout: Duration::from_secs(2),
},
)
.await
@@ -296,6 +308,7 @@ async fn start_configured_session<H: PluginHandler>(
message: Message::Ready {
protocol_version: PROTOCOL_VERSION,
ref incarnation,
..
},
ref payload,
} if incarnation == "test-incarnation" && payload.is_empty()
@@ -1,5 +1,6 @@
//! 插件 SDK 线协议、清单与客户端的测试入口
mod budget;
mod call;
#[cfg(feature = "io")]
mod client;
+4 -4
View File
@@ -1774,7 +1774,7 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin
"OpenAI 返回的 Codex PAT 身份资料不完整或格式无效,请稍后重试",
),
},
Error::InvalidInput => (Kind::Invalid, "OpenAI 账号输入格式不合法,请检查导入内容"),
Error::InvalidInput => (Kind::Invalid, "Codex 账号输入格式不合法,请检查导入内容"),
Error::InvalidCredential => (
Kind::Invalid,
"OpenAI 凭据不完整或格式无效,请检查凭据或重新授权",
@@ -1784,8 +1784,8 @@ fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdmin
Kind::Invalid,
refresh_rejection_message(code.as_deref()).unwrap_or("刷新令牌已失效,请重新授权"),
),
Error::AccountBanned { .. } => (Kind::Invalid, "OpenAI 账号已被停用,请检查账号状态"),
Error::NotFound => (Kind::NotFound, "OpenAI 账号不存在,请刷新账号列表"),
Error::AccountBanned { .. } => (Kind::Invalid, "Codex 账号已被停用,请检查账号状态"),
Error::NotFound => (Kind::NotFound, "Codex 账号不存在,请刷新账号列表"),
Error::RefreshLeaseUnavailable => {
(Kind::Conflict, "令牌刷新繁忙,请等待当前刷新完成后重试")
}
@@ -1920,7 +1920,7 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
"OpenAI 额度查询凭据无效,请检查账号授权",
None,
),
Error::NotFound => (Kind::NotFound, "OpenAI 额度查询账号不存在", None),
Error::NotFound => (Kind::NotFound, "Codex 额度查询账号不存在", None),
Error::RevisionConflict => (
Kind::Conflict,
"账号凭据已被更新,请刷新账号列表后重试",
@@ -7,7 +7,6 @@ use serde::Deserialize;
use url::Url;
use crate::credential::CodexQuotaRefreshPolicy;
use crate::transport::profile::CodexResidency;
use crate::transport::session::{CodexSessionIdentity, CodexSessionIdentityError};
use crate::transport::websocket::CodexWebSocketPoolConfig;
use crate::{
@@ -42,7 +41,9 @@ pub struct OpenAiConfig {
#[serde(default = "default_stream_max_retries")]
pub stream_max_retries: u64,
#[serde(default)]
pub residency: Option<CodexResidency>,
pub residency: Option<String>,
#[serde(default)]
pub account_routing_override: Option<String>,
#[serde(skip)]
identity_secret_path: PathBuf,
}
@@ -124,6 +125,7 @@ impl Default for OpenAiConfig {
auth: CodexAuthSettings::default(),
stream_max_retries: DEFAULT_STREAM_MAX_RETRIES,
residency: None,
account_routing_override: None,
identity_secret_path: PathBuf::new(),
}
}
+2 -1
View File
@@ -83,7 +83,8 @@ pub async fn initialize(
let credential_state = ports.credential_state();
let profile =
transport::profile::CodexWireProfileState::new(transport::profile::CodexWireProfile {
residency: config.residency,
residency: config.residency.clone(),
account_routing_override: config.account_routing_override.clone(),
..Default::default()
});
let artifact_cache =
@@ -734,11 +734,13 @@ fn websocket_connection_profile(
X_OPENAI_MEMGEN_REQUEST_HEADER,
"x-codex-guardian",
"x-openai-internal-codex-residency",
"x-openai-account-routing-override",
]
.map(|name| {
// 空字符串也是显式配置,必须与未发送的请求头区分
headers
.get(name)
.and_then(|value| value.to_str().ok())
.map(|value| format!("{name}:{}", hex::encode(value.as_bytes())))
.unwrap_or_default()
})
.join("\0");
@@ -11,11 +11,9 @@ use super::client::{
CodexBackendClient, CodexClientResult, CodexRequestContext, OpenAiUpstreamProtocol,
openai_subagent_from_metadata,
};
use super::profile::{CodexResidency, CodexWireProfile};
use super::profile::CodexWireProfile;
use super::protocol::responses::CodexResponsesRequest;
const CODEX_RESIDENCY_HEADER: &str = "x-openai-internal-codex-residency";
/// 不透传下游携带的认证、账号及相关身份字段,避免影响网关选定的上游身份
/// 上游需要的官方身份头由网关构造;这里也包含通用认证和 Cookie 字段
pub(super) fn is_managed_identity_header(name: &str) -> bool {
@@ -70,12 +68,16 @@ pub fn build_codex_profile_headers(profile: &CodexWireProfile) -> CodexClientRes
HeaderValue::from_str(&profile.originator)?,
);
headers.insert(USER_AGENT, HeaderValue::from_str(&profile.user_agent())?);
if let Some(CodexResidency::Us) = profile.residency {
headers.insert(
HeaderName::from_static(CODEX_RESIDENCY_HEADER),
HeaderValue::from_static("us"),
);
}
insert_optional_header(
&mut headers,
"x-openai-internal-codex-residency",
profile.residency.as_deref(),
)?;
insert_optional_header(
&mut headers,
"x-openai-account-routing-override",
profile.account_routing_override.as_deref(),
)?;
Ok(headers)
}
@@ -157,6 +157,7 @@ impl ExactIdentity {
fn profile(&self, state: &CodexWireProfileState) -> CodexWireProfile {
let environment = self.environment.as_ref();
let deployment = state.snapshot();
CodexWireProfile {
client_kind: if self.originator == "Codex Desktop" {
ClientKind::Desktop
@@ -180,7 +181,8 @@ impl ExactIdentity {
.map(|value| value.terminal.clone())
.unwrap_or_default(),
exact_user_agent: Some(self.user_agent.clone()),
residency: state.snapshot().residency,
residency: deployment.residency,
account_routing_override: deployment.account_routing_override,
verified_at: DateTime::UNIX_EPOCH,
}
}
@@ -40,13 +40,6 @@ const MAX_APPCAST_BYTES: usize = 1024 * 1024;
const ARTIFACT_PROFILE_CACHE_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
const ARTIFACT_PROFILE_SCHEMA_VERSION: u64 = 1;
/// 与官方 managed residency requirement 相同的可选请求约束
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CodexResidency {
Us,
}
/// 保留已有配置类型入口;位置规则由出口领域统一维护
pub use gateway_core::account::RequestLocation as CodexRequestLocation;
@@ -77,7 +70,9 @@ pub struct CodexWireProfile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exact_user_agent: Option<String>,
/// 未配置时不发送 residency 头;不随制品版本更新而改变
pub residency: Option<CodexResidency>,
pub residency: Option<String>,
/// 部署指定的账号路由头;未配置时不发送,不随客户端身份选择改变
pub account_routing_override: Option<String>,
/// 版本元组最后一次经制品核验的时间;不表示 TLS 传输已重新核验
pub verified_at: DateTime<Utc>,
}
@@ -96,6 +91,7 @@ impl Default for CodexWireProfile {
terminal: "unknown".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
// 制品核验于 2026-09-06T03:26:12.084Z;进程启动不构成重新核验
verified_at: DateTime::UNIX_EPOCH + chrono::Duration::milliseconds(1_788_665_172_084),
}
@@ -248,6 +248,7 @@ impl ClientProfileSelection {
verified_at: None,
},
};
let deployment = state.snapshot();
let mut profile = CodexWireProfile {
client_kind: self.client,
originator: self
@@ -271,7 +272,8 @@ impl ClientProfileSelection {
.clone()
.unwrap_or_else(|| "unknown".to_owned()),
exact_user_agent: None,
residency: state.snapshot().residency,
residency: deployment.residency,
account_routing_override: deployment.account_routing_override,
verified_at: release.verified_at.unwrap_or(DateTime::UNIX_EPOCH),
};
if let Some(entry) = self.cli_entry {
@@ -2443,7 +2443,7 @@ mod errors {
#[tokio::test]
async fn manual_refresh_preserves_banned_evidence_without_promoting_401_to_terminal() {
for (status, kind, message) in [
(400, Kind::Invalid, "OpenAI 账号已被停用,请检查账号状态"),
(400, Kind::Invalid, "Codex 账号已被停用,请检查账号状态"),
(
401,
Kind::BadGateway,
@@ -5,11 +5,36 @@ use std::path::Path;
use provider_openai::config::{MAX_STREAM_MAX_RETRIES, OpenAiConfig};
#[test]
fn openai_config_keeps_residency_separate_from_client_identity() {
use provider_openai::transport::profile::CodexResidency;
let config: OpenAiConfig = serde_json::from_str(r#"{"residency":"us"}"#).unwrap();
assert_eq!(config.residency, Some(CodexResidency::Us));
assert!(serde_json::from_str::<OpenAiConfig>(r#"{"residency":"invalid"}"#).is_err());
fn openai_config_preserves_optional_routing_header_values() {
for (document, expected) in [
(r#"{}"#, (None, None)),
(
r#"{"residency":null,"account_routing_override":null}"#,
(None, None),
),
(
r#"{"residency":"us","account_routing_override":"us"}"#,
(Some("us"), Some("us")),
),
(
r#"{"residency":"custom-region","account_routing_override":"custom-route"}"#,
(Some("custom-region"), Some("custom-route")),
),
(
r#"{"residency":"","account_routing_override":"NO_CONSTRAINT"}"#,
(Some(""), Some("NO_CONSTRAINT")),
),
] {
let config: OpenAiConfig = serde_json::from_str(document).unwrap();
assert_eq!(
(
config.residency.as_deref(),
config.account_routing_override.as_deref()
),
expected,
"{document}",
);
}
}
#[test]
@@ -69,6 +69,7 @@ fn wire_profile() -> CodexWireProfileState {
terminal: "catalog-contract".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -228,6 +228,7 @@ fn selector_with_runtime(
terminal: "selector-contract".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: chrono::Utc::now(),
});
let http = reqwest::Client::builder().build().expect("HTTP client");
@@ -26,6 +26,7 @@ fn wire_profile() -> CodexWireProfileState {
terminal: "profile-statistics-contract".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc
.with_ymd_and_hms(2026, 8, 28, 0, 0, 0)
.single()
@@ -46,6 +46,7 @@ fn wire_profile() -> CodexWireProfileState {
terminal: "quota-contract".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -1113,6 +1113,7 @@ fn wire_profile() -> CodexWireProfileState {
terminal: "provider-contract".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc::now(),
})
}
@@ -604,14 +604,14 @@ async fn verify_live_call_identity() {
};
use gateway_core::live::LiveHangupRequest;
use provider_openai::credential::{CodexCredentialAdmin, ImportCodexOAuthCredential};
use provider_openai::transport::profile::{CodexResidency, CodexWireProfile};
use provider_openai::transport::profile::CodexWireProfile;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
let profile = CodexWireProfile {
originator: "codex_cli_rs".to_owned(),
exact_user_agent: Some("codex_cli_rs/0.160.0 (Linux; x86_64) live-test".to_owned()),
codex_version: "0.160.0".to_owned(),
residency: Some(CodexResidency::Us),
residency: Some("us".to_owned()),
..CodexWireProfile::default()
};
let opaque_profile = |profile: &CodexWireProfile| {
@@ -427,6 +427,7 @@ fn profile() -> CodexWireProfileState {
terminal: "xterm".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -140,6 +140,7 @@ fn test_wire_profile() -> CodexWireProfileState {
terminal: "transport-test".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc::now(),
})
}
@@ -4,9 +4,7 @@ use gateway_core::account::OpaqueProviderData;
use provider_openai::transport::headers::build_codex_model_headers;
use provider_openai::transport::profile::identity::RequestProfileSelection;
use provider_openai::transport::profile::selection::ClientProfileSelection;
use provider_openai::transport::profile::{
CodexResidency, CodexWireProfile, CodexWireProfileState,
};
use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState};
use serde_json::{Value, json};
fn document(value: Value) -> OpaqueProviderData {
@@ -16,7 +14,8 @@ fn document(value: Value) -> OpaqueProviderData {
#[test]
fn complete_custom_identities_send_exact_user_agent_and_matching_companion_headers() {
let state = CodexWireProfileState::new(CodexWireProfile {
residency: Some(CodexResidency::Us),
residency: Some("us".to_owned()),
account_routing_override: Some("us_cr".to_owned()),
..Default::default()
});
for (core, originator, user_agent) in [
@@ -48,6 +47,7 @@ fn complete_custom_identities_send_exact_user_agent_and_matching_companion_heade
assert_eq!(headers["originator"], originator);
assert_eq!(headers["version"], core);
assert_eq!(headers["x-openai-internal-codex-residency"], "us");
assert_eq!(headers["x-openai-account-routing-override"], "us_cr");
assert!(profile.desktop_build.is_empty());
let frozen: CodexWireProfile =
serde_json::from_value(serde_json::to_value(&profile).unwrap()).unwrap();
@@ -153,6 +153,7 @@ fn wire_profile_should_generate_bundled_core_app_server_user_agent() {
terminal: "unknown".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
.single()
@@ -501,6 +502,7 @@ fn wire_profile() -> CodexWireProfile {
terminal: "unknown".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc
.with_ymd_and_hms(2026, 8, 3, 0, 0, 0)
.single()
@@ -1,8 +1,7 @@
//! 验证各端点共用当前客户端画像并保留显式驻留配置
//! 验证各端点共用当前客户端画像并保留部署指定的驻留与路由请求头
use super::*;
use provider_openai::transport::build_reqwest_client;
use provider_openai::transport::profile::CodexResidency;
use uuid::Uuid;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::any};
@@ -109,9 +108,10 @@ async fn each_core_endpoint_uses_the_current_bundled_release() {
}
#[test]
fn residency_is_explicit_and_survives_artifact_updates() {
fn deployment_routing_survives_artifact_updates_and_profile_selection() {
let mut initial = test_wire_profile().snapshot();
initial.residency = Some(CodexResidency::Us);
initial.residency = Some("us".to_owned());
initial.account_routing_override = Some("custom-route".to_owned());
let profile = CodexWireProfileState::new(initial);
profile.update_bundled_release(&CodexBundledReleaseProfile {
codex_version: "2.0.0".to_owned(),
@@ -119,7 +119,10 @@ fn residency_is_explicit_and_survives_artifact_updates() {
desktop_build: "9999".to_owned(),
verified_at: Utc::now(),
});
let snapshot = profile.snapshot();
let snapshot =
provider_openai::transport::profile::selection::ClientProfileSelection::default()
.resolve(&profile)
.expect("selected profile");
let model = provider_openai::transport::headers::build_codex_model_headers(
&snapshot,
"Bearer audit",
@@ -133,8 +136,145 @@ fn residency_is_explicit_and_survives_artifact_updates() {
)
.expect("account headers");
assert_eq!(model["x-openai-internal-codex-residency"], "us");
assert_eq!(model["x-openai-account-routing-override"], "custom-route");
assert_eq!(model["version"], "2.0.0");
assert!(!account.contains_key("x-openai-internal-codex-residency"));
assert!(!account.contains_key("x-openai-account-routing-override"));
}
#[tokio::test]
async fn model_transports_apply_configured_routing_and_discard_downstream_values() {
use base64::{Engine as _, engine::general_purpose::STANDARD};
use gateway_core::operation::{GenerateRequest, ProtocolPayload};
const HEADERS: [&str; 2] = [
"x-openai-internal-codex-residency",
"x-openai-account-routing-override",
];
for (document, expected) in [
(r#"{}"#, [None, None]),
(
r#"{"residency":null,"account_routing_override":null}"#,
[None, None],
),
(
r#"{"residency":"us","account_routing_override":"us"}"#,
[Some("us"), Some("us")],
),
(
r#"{"account_routing_override":"us_cr"}"#,
[None, Some("us_cr")],
),
(
r#"{"residency":"custom-region"}"#,
[Some("custom-region"), None],
),
(
r#"{"residency":"","account_routing_override":"custom-route"}"#,
[Some(""), Some("custom-route")],
),
(
r#"{"account_routing_override":"NO_CONSTRAINT"}"#,
[None, Some("NO_CONSTRAINT")],
),
] {
for websocket in [false, true] {
let (base_url, capture): (
String,
futures::future::BoxFuture<'static, reqwest::header::HeaderMap>,
) = if websocket {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut headers = None;
let mut socket = accept_codex_test_websocket_with(stream, |request, _| {
headers = Some(request.headers().clone());
})
.await;
socket.next().await.unwrap().unwrap();
socket
.send(Message::Text(
completed_websocket_response("resp_routing", 1, 1).into(),
))
.await
.unwrap();
headers.unwrap()
});
(base_url, Box::pin(async { task.await.unwrap() }))
} else {
let server = MockServer::start().await;
Mock::given(any())
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(format!(
"event: response.completed\ndata: {}\n\n",
completed_websocket_response("resp_routing", 1, 1),
)),
)
.expect(1)
.mount(&server)
.await;
(
server.uri(),
Box::pin(async move {
server.received_requests().await.unwrap()[0].headers.clone()
}),
)
};
let config: provider_openai::config::OpenAiConfig =
serde_json::from_str(document).unwrap();
let profile = CodexWireProfileState::new(CodexWireProfile {
residency: config.residency,
account_routing_override: config.account_routing_override,
..test_wire_profile().snapshot()
});
let client = CodexBackendClient::new(
reqwest::Client::builder().no_proxy().build().unwrap(),
base_url,
profile,
);
let downstream_headers = HEADERS
.into_iter()
.flat_map(|name| {
["downstream-first", "downstream-second"]
.map(|value| json!([name, STANDARD.encode(value)]))
})
.collect::<Vec<_>>();
let payload = ProtocolPayload::json_object(
"openai",
codex_request_body("gpt-test", "", Vec::new()),
)
.unwrap()
.with_context(Map::from_iter([
("opaque_request_headers".into(), json!(downstream_headers)),
("use_websocket".into(), json!(websocket)),
]));
let request = provider_openai::encode_generate_request(
&GenerateRequest::from_protocol_payload(payload),
"gpt-test",
None,
)
.unwrap();
client
.create_response(&request, request_context("req_routing", Some("account")))
.await
.expect("model response");
let headers = capture.await;
for (name, value) in HEADERS.into_iter().zip(expected) {
assert_eq!(
headers
.get_all(name)
.iter()
.map(|value| value.to_str().unwrap())
.collect::<Vec<_>>(),
value.into_iter().collect::<Vec<_>>(),
"{document}, websocket={websocket}, {name}",
);
}
}
}
}
#[tokio::test]
@@ -32,6 +32,7 @@ fn client(base_url: &str) -> CodexBackendClient {
terminal: "xterm-256color".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc
.with_ymd_and_hms(2026, 8, 20, 0, 0, 0)
.single()
@@ -885,6 +885,7 @@ fn profile() -> CodexWireProfileState {
terminal: "xterm".to_owned(),
exact_user_agent: None,
residency: None,
account_routing_override: None,
verified_at: Utc
.with_ymd_and_hms(2026, 7, 18, 0, 0, 0)
.single()
@@ -2152,8 +2152,24 @@ async fn guardian_header_should_open_a_distinct_websocket() {
#[tokio::test]
async fn residency_profile_should_open_a_distinct_websocket() {
assert_changed_handshake_opens_distinct_websocket("x-openai-internal-codex-residency", "us")
for value in ["us", ""] {
assert_changed_handshake_opens_distinct_websocket(
"x-openai-internal-codex-residency",
value,
)
.await;
}
}
#[tokio::test]
async fn account_routing_profile_should_open_a_distinct_websocket() {
for value in ["us_cr", ""] {
assert_changed_handshake_opens_distinct_websocket(
"x-openai-account-routing-override",
value,
)
.await;
}
}
async fn assert_changed_handshake_opens_distinct_websocket(
@@ -2227,9 +2243,15 @@ async fn assert_changed_handshake_opens_distinct_websocket(
)
.await
.unwrap();
let backend = if header == "x-openai-internal-codex-residency" {
let backend = if header != "x-codex-guardian" {
let mut profile = test_wire_profile().snapshot();
profile.residency = Some(provider_openai::transport::profile::CodexResidency::Us);
match header {
"x-openai-internal-codex-residency" => profile.residency = Some(value.to_owned()),
"x-openai-account-routing-override" => {
profile.account_routing_override = Some(value.to_owned())
}
_ => unreachable!("unknown profile header"),
}
backend.with_request_profile(profile)
} else {
backend
+6 -3
View File
@@ -231,7 +231,10 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`
| 代理位置 | “代理管理”;优先于全局位置,全局关闭时仍可独立生效 |
位置覆盖只影响 OpenAI 请求中受支持的位置与时区信息,不改变上下文日期、真实出口 IP、epoch 时间戳或系统时区。
`openai.residency` 是独立的部署约束。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置)。
`openai.residency` 和 `openai.account_routing_override` 是独立于客户端身份的启动配置,
分别指定 `x-openai-internal-codex-residency` 和 `x-openai-account-routing-override` 请求头。
两项均为可选字符串,填写值原样发送,不校验地域或路由枚举;省略或设为 `null` 时删除对应的下游请求头且不补发。
示例配置均填写 `us`,修改后重启应用生效。身份与位置字段见 [运行设置 API](../docs/api.md#8-运行设置)
后台账号操作使用 Provider 自己的官方画像;发布资料刷新不改写用户选择或 `config.yaml`
### 批量维护代理
@@ -387,7 +390,7 @@ V2 远程压缩通过 Responses 接口发送 `compaction_trigger`,网关和上
模板启用原生生图。Codex 可在任务需要图片时调用 `image_gen.imagegen`,
再通过代理的 Images 接口生成或编辑图片;也可以在需求中明确要求生成并使用图片。
需要支持该能力的客户端、支持图片输入的对话模型,以及有生图权限和额度的 OpenAI 账号。
需要支持该能力的客户端、支持图片输入的对话模型,以及有生图权限和额度的 Codex 账号。
代理不会增加上游权限,xAI 账号不能承接这些 Images 请求
生图不要求开启 WebSocket。要启用客户端 WebSocket,把当前 Provider 的
@@ -395,7 +398,7 @@ V2 远程压缩通过 Responses 接口发送 `compaction_trigger`,网关和上
客户端到代理、代理到上游是两段独立连接。客户端关闭 WebSocket 后,
服务端仍可能用 WebSocket 访问上游;客户端开关不控制服务端连接池和 HTTP 回退策略。
OpenAI OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不承接必须依赖 WS 的预热及连接内续接。
Codex OAuth 账号的上游传输方式默认 WS;固定为 SSE 的账号不承接必须依赖 WS 的预热及连接内续接。
仅使用这类账号时,客户端保持 `supports_websockets = false`,避免先尝试 WS 再回退
### 时区与客户端环境信息
+3 -1
View File
@@ -78,8 +78,10 @@ api:
request_id_header: 'x-request-id'
openai:
# 数据驻留约束;省略或 null 不指定地域约束,与管理端客户端身份无关。
# 数据驻留参考值:us;填写值原样发送,省略或 null 时删除对应请求头
residency: us
# 账号路由参考值:us、us_cr;填写值原样发送,省略或 null 时删除对应请求头
account_routing_override: us
# Compose 启动桥接区;密码仍只维护在 store 中。
services:
+11 -9
View File
@@ -293,8 +293,10 @@ API Key 与 OAuth 共用模拟客户端画像(`User-Agent`、`originator`、`v
包括会话、线程、Lite 和其他业务扩展头。
上游认证只来自选中的账号;API Key 不携带 OAuth Cookie、ChatGPT 账号身份或下游的
`X-OpenAI-Actor-Authorization` 托管认证声明。
下游的 `x-openai-account-routing-override`、`x-openai-fedramp` 也不透传,
工作区路由与合规属性不能从原账号继承。
下游的 `x-openai-internal-codex-residency`、`x-openai-account-routing-override`、`x-openai-fedramp`
也不透传,地域、工作区路由与合规属性不能从原账号继承。
前两项仅按部署配置 `openai.residency`、`openai.account_routing_override` 的字符串值生成,
省略或 `null` 时不发送,配置说明见 [部署与运维](../deploy/README.md#启动后的运行设置)
上述过滤描述原生转发;已信任插件的显式 header 改写可以覆盖账号与画像生成的默认头,
不按认证或会话字段名称拦截,边界见 [SDK 请求中间件](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#模型请求与-attempt)
@@ -1087,7 +1089,7 @@ OpenAI 的 CPR 导出保持 OAuth 账号的既有 token 与过期时间字段。
导入条目未提供 `modelAccess` 且 `settings` 未指定该字段时,新账号默认 `all`,已有账号保留原值;凭据刷新、重新授权和目录刷新均保留政策。
sub2api 的 `credentials.model_mapping` 不转换为本项目的账号模型限制
编辑 OpenAI API Key 账号时,在 `POST /api/admin/accounts/update` 的账号设置中附带 `connection`:
编辑 Codex API Key 账号时,在 `POST /api/admin/accounts/update` 的账号设置中附带 `connection`:
```json
{
@@ -1106,7 +1108,7 @@ sub2api 的 `credentials.model_mapping` 不转换为本项目的账号模型限
`connection.transport` 支持 `http`、`prefer_websocket`。省略 `connection` 时只更新账号设置;
省略 `connection.apiKey` 保留当前密钥,空字符串无效。
OpenAI OAuth 账号只接受 `connection: { transport }`,不接受 `baseUrl`、`apiKey` 或 OAuth token。
Codex OAuth 账号只接受 `connection: { transport }`,不接受 `baseUrl`、`apiKey` 或 OAuth token。
连接设置不能修改账号 ID、Provider 或认证类型,也不接受通用凭据文档。
凭据与设置在同一事务中保存,任一校验或持久化失败均不落库。
`GET /api/admin/accounts/detail` 对 API Key 账号额外返回 `credentialConfiguration: { base_url, transport }`;
@@ -1160,7 +1162,7 @@ OAuth start 使用:
失败会释放流程占用,但上游 authorization code 通常只能交换一次;已完成 token exchange 而未提交账号时应重新创建 OAuth flow
- `GET /api/admin/accounts/quota` 只读取最后一次落库快照;`POST /api/admin/accounts/quota/refresh` 才访问上游。access token
已过期时,额度刷新要求先走 credential 刷新或重新授权,不会拿过期 token 探测额度
- OpenAI 已耗尽账号首次可复核,在 `resetAt` 尚未到达或未知时按 30 分钟间隔兜底,也可发现
- Codex 已耗尽账号首次可复核,在 `resetAt` 尚未到达或未知时按 30 分钟间隔兜底,也可发现
官方提前重置。到达最早未恢复窗口的 `resetAt` 后,先等待 10 秒传播余量,再在下一轮后台检查
复核;reset 后前 5 分钟最小查询间隔为 30 秒,之后仍耗尽则改为 5 分钟。正常账号有非零用量或触顶窗口时,仍在
`resetAt + 2 分钟` 后复核,未更新则按 30 分钟间隔重试。后台每 30 秒检查条件,实际恢复时间还受
@@ -1225,7 +1227,7 @@ OAuth start 使用:
等价 USD 费用不是订阅价格或额度承诺,30 天折算不是自然月额度,记录覆盖率也不代表预测准确率
### OpenAI 账号辅助请求
### Codex 账号辅助请求
OAuth 账号的额度、个人资料、订阅与重置卡请求先使用 `openai.api.base_url`。自定义上游明确返回 HTTP 404 时,
仅回退一次到官方账号接口,沿用当前账号凭据与出站代理;其他状态码、解析错误或传输失败不触发回退。
@@ -1235,7 +1237,7 @@ OAuth 账号的额度、个人资料、订阅与重置卡请求先使用 `openai
### 账号个人信息
`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,提供 OpenAI/Codex OAuth 账号的个人信息。后端并发读取资料统计与订阅,
`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,提供 Codex OAuth 账号的个人信息。后端并发读取资料统计与订阅,
一次返回;每次请求均重新查询,不刷新 credential,不读取本地 usage/billing 记录,也不缓存或估算统计结果。
查询沿用上述 OpenAI 404 路由回退
@@ -1558,7 +1560,7 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
定时账号预热默认关闭。`accountWarmupScheduleTime` 使用部署时区中的 `HH:MM`,
多个时段以逗号分隔,默认 `08:00`;`accountWarmupModel` 默认 `null`,开启前必须显式选择模型。
任务面向可用的 OpenAI OAuth 账号,跳过周额度耗尽及五小时窗口距离重置仍超过 30 分钟的账号。
任务面向可用的 Codex OAuth 账号,跳过周额度耗尽及五小时窗口距离重置仍超过 30 分钟的账号。
不存在的本地时刻跳过,重复时刻只执行较早一次;执行进度跨重启保留,时钟回拨不补跑已领取时刻之前的时段。
只有收到响应成功终态才记为预热成功;预热不计入客户端业务用量
@@ -1653,7 +1655,7 @@ Guardian 以 `subagent_kind` 或 `client_metadata.x-openai-subagent` 值 `guardi
### 账号亲和与换号
`openaiAccountAffinity` 控制 OpenAI 账号亲和,默认 `strict`(严格):
`openaiAccountAffinity` 控制 Codex 账号亲和,默认 `strict`(严格):
- `relaxed`(宽松):会话内请求直接按 `rotationStrategy` 选号,不优先主账号
- `preferred`(优先):会话内所有可关联请求优先主账号,并发已满、额度耗尽、停用、限流冷却或不支持当前模型时临时分流,保留会话绑定,后续请求仍优先主账号
+5 -2
View File
@@ -212,6 +212,9 @@ flowchart LR
#### 调用资源与状态
- **调用预算由阶段合同统一解释**:SDK 定义场景默认值,插件在 `Ready` 声明所需预算,Runtime 与 SDK 解析并冻结到会话;各入口按实例和阶段读取,宿主不提供部署侧业务时长上限。
注册在进程启动与握手完成后使用自己的预算;CLI 的执行与结果提交共用一次期限,重试委托链共享匹配实例中最长的预算并受请求剩余期限约束,单次调用仍受自身声明限制。
业务预算不延长进程启动与握手、排队、取消确认、关闭和外部请求期限;声明与默认值见 [SDK 调用预算](../backend/crates/gateway-plugin/sdk/README.md#声明调用预算)
- **资源跟随调用**:账号、HTTP、模型、状态和日志回调绑定有效父调用,取消随调用传播。HTTP 与模型 request/attempt 中间件在首个流式响应头交付后,由正文 owner 管理取消与终态,不沿用初始处理期限;网络操作与模型执行各自计时。
Runtime 与 SDK 的中间件处理预算在宿主 `next` 等待期间暂停;Runtime 同时归还执行名额,返回后取得名额再以剩余处理预算恢复原调用。
执行调度由 Runtime 统一负责,准入和恢复等待使用独立期限;初次排队、总在途上下文和普通回调各有上限;`next` 回调按在途上限独立计数。
@@ -220,7 +223,7 @@ flowchart LR
`frontend_authentication` 使用 principal 到 Key 的映射;公开登录回调的一次性票据用于关联流程,不限制插件回调能力。接口合同见 [SDK 回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#宿主资源回调)
- **观察合同统一**:`observer` 通过一个类型化 RPC 入口接收完成与实际上游 WebSocket 事件,发布时按事件类型编译独立作用域。完成事件直接携带终态、用量、费用和耗时,不按能力声明裁剪字段;异步投递的并发、队列和载荷有界,观察失败不改变业务结果
- **维护只作用于已发布实例**:`maintenance` 由 Host 监督的 Runtime worker 调用,启用、恢复、配置发布和周期补偿共用对账入口。
发布通知只用于唤醒,事实始终来自当前快照与数据库;同实例串行,通知合并,停用与版本替换取消旧任务。
发布通知只用于唤醒,事实始终来自当前快照与数据库;按发布层复用的实例身份保留在途任务,无关实例变更不重启维护。同实例串行,通知合并;自身停用、替换或恢复时取消旧任务,等取消确认或进程退出后启动替代任务。
自有分组和 Key 按实例、资源类型与稳定资源键登记;Admin 组织用例,Store 在同一事务复核当前启用 revision、
制品接受事实与归属,并复用原生资源写入、审计和配置发布。无变化不递增 revision;插件不能接管管理员资源,
成员变更不覆盖账号的其他分组。停用保留资源;删除实例只解除归属
@@ -438,7 +441,7 @@ Client Key 鉴权完成后,API adapter 从有界请求头识别客户端,Cor
Responses 按模型目录编译候选;全局模型映射是精确映射,未命中时模型名原样交给候选 Provider。
Images 与 standalone Search 是 OpenAI Provider 自有端点:两者都不参与文本模型映射,只在 Client Key
的账号范围确实包含 OpenAI 账号时生成单一 OpenAI 候选。Images 的图片模型由 Provider 从正文旁路
的账号范围确实包含 Codex 账号时生成单一 OpenAI 候选。Images 的图片模型由 Provider 从正文旁路
提取,用于账号模型政策检查,并在中间件改写后复验当前租约账号;不要求图片模型进入文本目录。
Search 的请求身份与正文透传规则见 [OpenAI 透传](api.md#openai-透传)
+2 -1
View File
@@ -75,6 +75,8 @@ pnpm --dir frontend dev
| `modules/ui` | `codex-proxy-ui`,共用组件与主题 |
| `modules/plugins` | `codex-proxy-plugins`,官方插件示例 |
各仓库的常规 CI 验证自身代码和锁定依赖。源码模式用于按需联调,运行前确认所选版本或提交组合及其公开合同兼容
### 检出与安装
首次检出可同时下载子模块:
@@ -114,7 +116,6 @@ pnpm --dir modules/plugins/examples/workbench/frontend build:source
```
`build:source` 用各项目的 `tsconfig.source.json` 检查源码类型,管理端构建到 `frontend/node_modules/.vite/source-dist`,示例构建到其前端项目的 `.vite/source-dist`,不改写正式依赖、锁文件或 `dist`。
源码联调 CI 显式检出子模块,分别按锁文件安装依赖,验证这两个命令并确认联调未改写正式依赖和锁文件
### 本地 SDK 验证
+5 -6
View File
@@ -58,10 +58,9 @@
"checkBasicSettings": "Please check basic settings",
"noParameters": "No plugin parameters required",
"sensitiveConfiguration": "Sensitive configuration",
"secretsHelpTitle": "About secret configuration",
"secretsHelp": "Secrets are stored separately and never displayed, Keep secrets preserves existing values and Replace all secrets overwrites them with all values entered here",
"secretsHelp": "Saved tokens, keys and other private values are never shown",
"settingsSaved": "Settings saved: {count}",
"clearSecretsHelp": "Clear all secrets when saving",
"emptySecretPlaceholder": "Leave blank to clear this value",
"about": "About {name}",
"enterNewValue": "Enter new value",
"pluginParameters": "Parameters",
@@ -70,9 +69,9 @@
"applyAndSwitch": "Apply and switch",
"saveAndEnable": "Save and enable",
"saveSettings": "Save",
"keepExistingValues": "Keep secrets",
"replaceAllValues": "Replace all secrets",
"clearAllValues": "Clear all secrets",
"keepExistingValues": "Keep existing",
"replaceAllValues": "Replace all",
"clearAllValues": "Clear all",
"checkPluginParameters": "Please check plugin parameters",
"setRequestScopeOrCheckApplyTo": "Please set the request scope, or check \"Apply to all requests\"",
"invalidIdentityMappings": "Check the client authentication identity mappings",
+2 -2
View File
@@ -150,7 +150,7 @@
},
"scheduling": {
"roundRobin": "Round robin",
"autoReviewSlotsHelp": "Applies to OpenAI accounts only: with a regular concurrency limit of 10 and 3 auto review slots, up to 10 regular requests and 3 auto review requests can run independently",
"autoReviewSlotsHelp": "Applies to Codex accounts only: with a regular concurrency limit of 10 and 3 auto review slots, up to 10 regular requests and 3 auto review requests can run independently",
"autoReviewQueueHelp": "With dedicated slots, auto review uses a separate queue that may exceed account queue capacity, while total queue capacity, queue timeout, and API key limits still apply",
"weights": {
"load": "Load weight",
@@ -282,7 +282,7 @@
"queueTimeoutHelp": "Shared by both queues, 1–120 seconds",
"autoReview": {
"label": "Reserved auto review slots",
"description": "Extra slots per OpenAI account, 0 shares regular concurrency",
"description": "Extra slots per Codex account, 0 shares regular concurrency",
"helpTitle": "About auto review concurrency"
},
"queueTimeoutHelpTitle": "About queue timeout",
@@ -70,7 +70,8 @@
"plugins": {
"title": "Plugin compatibility notice",
"description": "Keep enabled configurations and try starting each plugin after restarting",
"incompatible": "Incompatible plugins"
"incompatible": "Incompatible plugins",
"requestErrorHelp": "If requests fail even after plugins start successfully, try disabling the relevant plugins and retrying"
},
"versionAfterRestart": "Version after restart",
"confirmation": {
@@ -132,10 +132,10 @@
"urlLength": "上游 API 地址不能超过 2048 个字符",
"urlFormat": "请输入不含认证、查询参数或片段的 HTTP 或 HTTPS 地址",
"urlRequired": "请输入完整的上游 API 地址",
"apiKeyFormat": "API Key 不能包含空格或控制字符",
"apiKeyFormat": "API密钥不能包含空格或控制字符",
"transportRequired": "请选择传输方式"
},
"apiKeyRequired": "请输入 API Key"
"apiKeyRequired": "请输入 API密钥"
},
"batchEdit": {
"title": "批量编辑账号",
@@ -167,7 +167,7 @@
"loadError": "上游设置读取失败",
"notesHelp": "最多 500 字,留空可清除备注",
"oauthUnavailable": "该账号没有 OAuth 上游设置",
"apiKeyUnavailable": "该账号没有 API Key 上游设置",
"apiKeyUnavailable": "该账号没有 API密钥上游设置",
"saved": "账号已更新"
},
"filters": {
@@ -347,8 +347,8 @@
"accountAdded": "账号已添加",
"providerRequired": "请选择账号平台",
"accountPlatformIsNotSupported": "账号平台不受支持",
"apiKeyUnsupported": "当前平台不支持 API Key 账号",
"apiKeyAccountAdded": "API Key 账号已添加",
"apiKeyUnsupported": "当前平台不支持 API密钥账号",
"apiKeyAccountAdded": "API密钥账号已添加",
"importTasksCreated": "已创建 {submitted} 个导入任务",
"importTaskCreated": "导入任务已创建",
"partialSubmission": "部分导入任务已创建,保持输入不变重试可继续提交",
@@ -111,7 +111,7 @@
},
"dashboard": {
"justUpdated": "刚刚更新",
"demoKey": "演示 Key",
"demoKey": "演示 API密钥",
"requestHealthTimeline": "请求健康时间线",
"validRequestAvailability": "有效请求可用性",
"todaySToken": "今日 Token",
+4 -4
View File
@@ -2,7 +2,7 @@
"auth": {
"signOut": "退出登录",
"adminSignIn": "管理员登录",
"apiKeySignIn": "API Key 登录",
"apiKeySignIn": "API密钥登录",
"signingIn": "正在登录",
"signIn": "登录",
"method": "选择登录方式",
@@ -11,11 +11,11 @@
"username": "管理员账号",
"usernamePlaceholder": "输入会话账号",
"passwordPlaceholder": "输入会话密钥",
"apiKeyPlaceholder": "输入 API Key",
"apiKeyPlaceholder": "输入 API密钥",
"pageLabel": "Codex Proxy RS 登录",
"password": "密码",
"apiKey": "API 密钥",
"apiKey": "API密钥",
"adminRealm": "管理员",
"keyRealm": "API 密钥"
"keyRealm": "API密钥"
}
}
@@ -28,7 +28,7 @@
"malformedJson": "请求体不是合法 JSON",
"invalidTimeRange": "时间范围不合法",
"invalidCredentials": "登录凭据错误",
"invalidAdminApiKey": "管理 API 密钥无效",
"invalidAdminApiKey": "管理 API密钥无效",
"tooManyLoginAttempts": "登录尝试过多,请稍后重试",
"upstreamResultUnknown": "上游执行结果未知,请刷新状态后再决定是否重试"
},
@@ -112,7 +112,7 @@
"name": "名称",
"createdAt": "创建时间",
"baseUrl": "基础 URL",
"apiKey": "API 密钥",
"apiKey": "API密钥",
"build": "构建"
},
"language": "界面语言",
@@ -53,7 +53,7 @@
"capacity": "容量"
},
"page": {
"description": "将账号归类管理,并为每个 API 密钥指定可使用的账号",
"description": "将账号归类管理,并为每个 API密钥指定可使用的账号",
"empty": "暂无分组",
"selectCurrentPageGroup": "选择当前页分组",
"selectGroup": "选择分组",
@@ -64,11 +64,11 @@
"batchDeleteWarning": "删除后这些分组将立即失效,此操作不可撤销",
"batchDeleteDescription": "确定删除选中的 {count} 个分组吗?账号本身不会被删除",
"disableAccountGrouping": "禁用账号分组",
"disableDescription": "禁用后,使用该分组的 API 密钥将无法再使用其中的账号",
"disableDescription": "禁用后,使用该分组的 API密钥将无法再使用其中的账号",
"confirmDisable": "确认禁用",
"disable": "确定禁用“{name}”吗?",
"affectedApiKeys": "将影响 {count} 个 API 密钥:{value}",
"affectedKeys": "将影响 {count} 个 API 密钥",
"affectedApiKeys": "将影响 {count} 个 API密钥:{value}",
"affectedKeys": "将影响 {count} 个 API密钥",
"deleteAccountGroup": "删除账号分组",
"deleteWarning": "删除后该分组将立即失效,此操作不可撤销",
"unnamedGroup": "该分组",
+18 -18
View File
@@ -9,7 +9,7 @@
"ccSwitchDescription": "管理和切换 AI 客户端配置",
"viewOnGitHub": "在 GitHub 查看 {name}",
"copiedToClipboard": "已复制到剪贴板",
"fullApiKeyNotAvailable": "完整 API Key 不可用",
"fullApiKeyNotAvailable": "完整 API密钥不可用",
"title": "使用密钥"
},
"actions": {
@@ -20,7 +20,7 @@
"disableKey": "停用",
"enableKey": "启用",
"deleteKey": "删除",
"deletedApiKeyDeletedApiKeys": "已删除 {count} 个 API Key",
"deletedApiKeyDeletedApiKeys": "已删除 {count} 个 API密钥",
"configure": "配置"
},
"day": "日",
@@ -43,24 +43,24 @@
"confirmReset": "确认重置"
},
"created": {
"apiKeyCreated": "API Key 已创建",
"apiKeyCreated": "API密钥已创建",
"description": "复制密钥,或导入到客户端",
"accessNotice": "该密钥具有网关访问权限,请仅发送给可信调用方",
"iHaveSaved": "我已保存"
},
"filters": {
"apiKeyFilteringAndOperations": "API Key 筛选与操作",
"apiKeyFilteringAndOperations": "API密钥筛选与操作",
"searchPlaceholder": "搜索名称或标签",
"searchLabel": "搜索 API Key 名称或标签",
"searchLabel": "搜索 API密钥名称或标签",
"deleteSelected": "删除 ({count})",
"createApiKey": "创建 API Key"
"createApiKey": "创建 API密钥"
},
"editor": {
"description": "配置密钥信息、分组与使用限制",
"namePlaceholder": "例如:生产环境",
"labelOptional": "标签(可选)",
"forExampleBackendService": "例如:后端服务",
"customKeyOptional": "自定义 Key(可选)",
"customKeyOptional": "自定义 API密钥(可选)",
"keyPlaceholder": "留空自动生成",
"upstreamIdentity": "上游身份",
"dailyLimit": "日限额",
@@ -69,14 +69,14 @@
"weeklyLimitUsd": "周限额(USD)",
"maximumConcurrency": "最大并发",
"requestsPerMinuteRpm": "每分钟请求数(RPM)",
"updated": "API Key 已更新",
"created": "API Key 已创建",
"updated": "API密钥已更新",
"created": "API密钥已创建",
"validation": {
"amount": "{label}必须是非负金额,最多 10 位小数",
"integer": "{label}必须是非负整数"
},
"enterApiKeyName": "请输入 API Key 名称",
"invalidKey": "自定义 Key 只能包含 HTTP 可传输的可见字符,不能包含空格或换行",
"enterApiKeyName": "请输入 API密钥名称",
"invalidKey": "自定义 API密钥只能包含 HTTP 可传输的可见字符,不能包含空格或换行",
"requestsPerMinute": "每分钟请求数"
},
"fields": {
@@ -88,16 +88,16 @@
"lastUsed": "最后使用"
},
"page": {
"description": "管理 API 密钥及账号访问范围",
"noApiKeyYet": "暂无 API Key",
"description": "管理 API密钥及账号访问范围",
"noApiKeyYet": "暂无 API密钥",
"selectCurrentPageKey": "选择当前页密钥",
"selectKey": "选择密钥",
"concurrency": "并发",
"batchDeleteWarning": "删除后这些 API Key 将立即失效,此操作不可撤销",
"batchDeleteDescription": "确定删除选中的 {count} 个 API Key 吗?",
"deleteApiKey": "删除 API Key",
"deleteWarning": "删除后该 API Key 将立即失效,此操作不可撤销",
"apiKey": "该 API Key",
"batchDeleteWarning": "删除后这些 API密钥将立即失效,此操作不可撤销",
"batchDeleteDescription": "确定删除选中的 {count} 个 API密钥吗?",
"deleteApiKey": "删除 API密钥",
"deleteWarning": "删除后该 API密钥将立即失效,此操作不可撤销",
"apiKey": "该 API密钥",
"delete": "确定删除 {name} 吗?"
},
"scope": {
@@ -4,7 +4,7 @@
"accounts": "账号管理",
"proxies": "代理管理",
"groups": "分组管理",
"apiKeys": "API 密钥",
"apiKeys": "API密钥",
"usage": "分析统计",
"plugins": "插件",
"appearance": "主题设置",

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