mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
refactor(plugins)!: separate identity queries and cooperative scheduling (#525)
Resolve identity and frozen access facts without constructing model execution contexts. Coordinate parent task suspension, resumption and retained callbacks at the SDK and Runtime boundary, with bounded admission and cancellation. Expose account HTTP, client verification, access facts and quota details through the existing host owners. BREAKING CHANGE: Plugin sessions require RPC v7; rebuild and repackage plugins with the matching SDK. No legacy protocol negotiation or fallback is provided. Co-authored-by: Zyy <zyycn@users.noreply.github.com> Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -16,8 +16,9 @@ use crate::{
|
||||
pub const MAXIMUM_AUTHORIZATION_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// API 从数据面 Authorization 头构造的有界认证信封
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
#[derive(Clone)]
|
||||
pub struct ClientAuthenticationRequest {
|
||||
invocation: Option<Arc<super::extensions::InvocationOrigin>>,
|
||||
request_context: Option<Arc<super::extensions::RequestContext>>,
|
||||
settings: Option<crate::routing::request_settings::RequestSettings>,
|
||||
authorization: Arc<str>,
|
||||
@@ -25,6 +26,18 @@ pub struct ClientAuthenticationRequest {
|
||||
}
|
||||
|
||||
impl ClientAuthenticationRequest {
|
||||
/// 子认证继承受信调用的来源、取消和副作用归属,不据此跳过认证器
|
||||
#[must_use]
|
||||
pub fn with_invocation(mut self, origin: super::extensions::InvocationOrigin) -> Self {
|
||||
self.invocation = Some(Arc::new(origin));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invocation(&self) -> Option<&super::extensions::InvocationOrigin> {
|
||||
self.invocation.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_request_context(
|
||||
mut self,
|
||||
@@ -35,12 +48,18 @@ impl ClientAuthenticationRequest {
|
||||
}
|
||||
#[must_use]
|
||||
pub fn request_context(&self) -> Option<&super::extensions::RequestContext> {
|
||||
self.request_context.as_deref()
|
||||
self.invocation
|
||||
.as_ref()
|
||||
.and_then(|origin| origin.extensions.request())
|
||||
.or(self.request_context.as_deref())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn shared_request_context(&self) -> Option<Arc<super::extensions::RequestContext>> {
|
||||
self.request_context.clone()
|
||||
self.invocation
|
||||
.as_ref()
|
||||
.and_then(|origin| origin.extensions.shared_request())
|
||||
.or_else(|| self.request_context.clone())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -79,6 +98,7 @@ impl ClientAuthenticationRequest {
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| PlaintextClientApiKey::new(value.to_owned()).ok());
|
||||
Ok(Self {
|
||||
invocation: None,
|
||||
settings: None,
|
||||
request_context: None,
|
||||
authorization: authorization.into(),
|
||||
|
||||
@@ -12,7 +12,6 @@ pub use contract::{
|
||||
StartedExecution,
|
||||
};
|
||||
mod scope;
|
||||
pub use scope::BoundModelExecutionContext;
|
||||
mod service;
|
||||
pub use failure::gateway_error_from_engine;
|
||||
pub use service::DefaultExecutionService;
|
||||
|
||||
@@ -15,13 +15,10 @@ use crate::{
|
||||
lifecycle::{CancellationToken, Deadline},
|
||||
operation::Operation,
|
||||
policy::ClientApiKeyId,
|
||||
routing::{
|
||||
FrozenAccountScope, PublicModelId, UpstreamModelId, request_settings::RequestSettings,
|
||||
},
|
||||
routing::{FrozenAccountScope, PublicModelId, UpstreamModelId},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt,
|
||||
sync::{
|
||||
Arc, Mutex, Weak,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
@@ -188,57 +185,6 @@ pub(super) struct ActiveRequestLease {
|
||||
pub(super) authority: Arc<ActiveRequestAuthority>,
|
||||
}
|
||||
|
||||
/// 管理页或 CLI 一次调用持有的显式模型执行身份
|
||||
///
|
||||
/// 结构不暴露 Key 明文;Runtime 只能把它原样交回 Core 发起模型请求
|
||||
#[derive(Clone)]
|
||||
pub struct BoundModelExecutionContext {
|
||||
pub(super) authority: Arc<BoundModelExecutionAuthority>,
|
||||
}
|
||||
|
||||
impl BoundModelExecutionContext {
|
||||
/// 与实际执行共用绑定时的快照,不能回退到后续发布的配置
|
||||
#[must_use]
|
||||
pub fn request_body_limit(&self) -> usize {
|
||||
self.authority
|
||||
.client
|
||||
.snapshot
|
||||
.responses_max_decompressed_body_bytes()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn request_settings(&self) -> Option<&RequestSettings> {
|
||||
self.authority.client.request_settings()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct BoundModelExecutionAuthority {
|
||||
pub(super) client: AuthenticatedClient,
|
||||
pub(super) account_scope: Arc<FrozenAccountScope>,
|
||||
pub(super) cancellation: CancellationToken,
|
||||
pub(super) extension_scope: ExtensionCallScope,
|
||||
pub(super) initiating_plugin_instance_id: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for BoundModelExecutionContext {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BoundModelExecutionContext")
|
||||
.field("key_id", &self.authority.client.policy.key_id())
|
||||
.field(
|
||||
"initiating_plugin_instance_id",
|
||||
&self.authority.initiating_plugin_instance_id,
|
||||
)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BoundModelExecutionAuthority {
|
||||
fn drop(&mut self) {
|
||||
self.cancellation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveRequestLease {
|
||||
pub(super) fn release(self) {
|
||||
drop(self);
|
||||
|
||||
@@ -12,9 +12,8 @@ use super::contract::{
|
||||
use super::probe_store::{ProbeObservation, TransientExecutionStore};
|
||||
use super::scope::{
|
||||
ActiveRequestAuthority, ActiveRequestLease, ActiveRequestRegistry, AuthorizedExecution,
|
||||
BoundExecutionFacts, BoundModelExecutionAuthority, BoundModelExecutionContext,
|
||||
ExecutionStartGuard, ExecutionTarget, NestedExecutionFacts, NestedExecutionGraph,
|
||||
PendingStartExecution, PreparedExecutionStart,
|
||||
BoundExecutionFacts, ExecutionStartGuard, ExecutionTarget, NestedExecutionFacts,
|
||||
NestedExecutionGraph, PendingStartExecution, PreparedExecutionStart,
|
||||
};
|
||||
use super::{COORDINATION_TIMEOUT, DIAGNOSTIC_TIMEOUT};
|
||||
use super::{scope::ExecutionAdmission, session::DefaultExecutionSession};
|
||||
@@ -41,8 +40,8 @@ use crate::{
|
||||
middleware::FrozenMiddlewarePlan,
|
||||
nested::{
|
||||
AffinityLookupPort, AffinityLookupRequest, AffinityLookupResult,
|
||||
BoundModelExecutionBinding, BoundModelExecutionRequest, ExecutionEffects,
|
||||
NestedModelExecutionPort, NestedModelExecutionRequest,
|
||||
BoundModelExecutionRequest, ExecutionEffects, NestedModelExecutionPort,
|
||||
NestedModelExecutionRequest,
|
||||
},
|
||||
observation::{
|
||||
FrozenRequestObservationContext, RequestObservationDispatch, RequestObservationScope,
|
||||
@@ -1081,72 +1080,54 @@ impl DefaultExecutionService {
|
||||
})
|
||||
}
|
||||
|
||||
async fn bind_bound_model_inner(
|
||||
&self,
|
||||
binding: BoundModelExecutionBinding,
|
||||
) -> Result<BoundModelExecutionContext, GatewayError> {
|
||||
let client = self
|
||||
.client_for_key_id(&binding.client_key_id, binding.settings)
|
||||
.await?;
|
||||
if binding.extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH {
|
||||
return Err(GatewayError::new(
|
||||
GatewayErrorKind::PolicyDenied,
|
||||
"plugin model execution depth exceeded",
|
||||
));
|
||||
}
|
||||
let extension_scope = binding
|
||||
.extension_scope
|
||||
.extending(binding.initiating_plugin_instance_id.clone())
|
||||
.ok_or_else(|| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"bound plugin execution scope is invalid",
|
||||
)
|
||||
})?;
|
||||
let account_scope = Arc::clone(client.policy.account_scope());
|
||||
Ok(BoundModelExecutionContext {
|
||||
authority: Arc::new(BoundModelExecutionAuthority {
|
||||
client,
|
||||
account_scope,
|
||||
cancellation: binding.cancellation,
|
||||
extension_scope,
|
||||
initiating_plugin_instance_id: binding.initiating_plugin_instance_id,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn start_bound_model_inner(
|
||||
&self,
|
||||
request: BoundModelExecutionRequest,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
let BoundModelExecutionRequest {
|
||||
context,
|
||||
client,
|
||||
initiating_plugin_instance_id,
|
||||
cancellation,
|
||||
extension_scope,
|
||||
public_model,
|
||||
operation,
|
||||
mut metadata,
|
||||
provider,
|
||||
account,
|
||||
} = request;
|
||||
let authority = &context.authority;
|
||||
if authority.cancellation.is_cancelled() {
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(GatewayError::new(
|
||||
GatewayErrorKind::Cancelled,
|
||||
"bound plugin invocation was cancelled",
|
||||
));
|
||||
}
|
||||
let now = SystemTime::now();
|
||||
let deadline_at = Deadline::from_timeout(now, authority.client.execution_timeout())
|
||||
if extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH {
|
||||
return Err(GatewayError::new(
|
||||
GatewayErrorKind::PolicyDenied,
|
||||
"plugin model execution depth exceeded",
|
||||
));
|
||||
}
|
||||
let extension_scope = extension_scope
|
||||
.extending(initiating_plugin_instance_id.clone())
|
||||
.ok_or_else(|| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::PolicyDenied,
|
||||
"plugin model execution scope is recursive",
|
||||
)
|
||||
})?;
|
||||
let now = SystemTime::now();
|
||||
let deadline_at =
|
||||
Deadline::from_timeout(now, client.execution_timeout()).ok_or_else(|| {
|
||||
GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid")
|
||||
})?;
|
||||
let (account_scope, required_provider) = restrict_model_execution_scope(
|
||||
authority.account_scope.as_ref(),
|
||||
client.policy.account_scope().as_ref(),
|
||||
provider,
|
||||
account.as_ref(),
|
||||
None,
|
||||
)?;
|
||||
// 连接承载多个独立执行;递归预算和副作用只在本次执行的子调用图内共享
|
||||
// 父连接的取消仍通过 authority 传播,不能因新建执行丢失扩展调用链
|
||||
// 父调用的取消继续向子执行传播,不能因新建执行丢失扩展调用链
|
||||
let graph = Arc::new(NestedExecutionGraph::new(Arc::new(
|
||||
ExecutionEffects::default(),
|
||||
)));
|
||||
@@ -1154,12 +1135,12 @@ impl DefaultExecutionService {
|
||||
let execution_effects_baseline = graph.effects.epoch();
|
||||
metadata.endpoint = "host.model".to_owned();
|
||||
metadata.transport = ClientTransport::InternalPlugin;
|
||||
metadata.inherit_request_origin(&authority.extension_scope);
|
||||
metadata.inherit_request_origin(&extension_scope);
|
||||
self.client_api_key_usage
|
||||
.record_used(authority.client.policy.key_id());
|
||||
.record_used(client.policy.key_id());
|
||||
self.start_authorized(
|
||||
PendingStartExecution {
|
||||
client: authority.client.clone(),
|
||||
client,
|
||||
target: ExecutionTarget::Model(public_model),
|
||||
operation,
|
||||
metadata,
|
||||
@@ -1168,13 +1149,13 @@ impl DefaultExecutionService {
|
||||
account_scope,
|
||||
response_control: None,
|
||||
deadline_at,
|
||||
cancellation: authority.cancellation.child_token(),
|
||||
extension_scope: authority.extension_scope.clone(),
|
||||
cancellation: cancellation.child_token(),
|
||||
extension_scope,
|
||||
required_provider,
|
||||
required_account: account,
|
||||
nested: None,
|
||||
bound: Some(BoundExecutionFacts {
|
||||
initiating_plugin_instance_id: authority.initiating_plugin_instance_id.clone(),
|
||||
initiating_plugin_instance_id,
|
||||
}),
|
||||
graph: Some(graph),
|
||||
execution_effects_baseline,
|
||||
@@ -1725,22 +1706,54 @@ impl ExecutionService for DefaultExecutionService {
|
||||
}
|
||||
|
||||
impl NestedModelExecutionPort for DefaultExecutionService {
|
||||
fn resolve_client(
|
||||
&self,
|
||||
client_key_id: ClientApiKeyId,
|
||||
settings: Option<RequestSettings>,
|
||||
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>> {
|
||||
Box::pin(async move { self.client_for_key_id(&client_key_id, settings).await })
|
||||
}
|
||||
|
||||
fn verify_client(
|
||||
&self,
|
||||
request: ClientAuthenticationRequest,
|
||||
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>> {
|
||||
Box::pin(async move {
|
||||
let cancellation = request
|
||||
.invocation()
|
||||
.map(|origin| origin.cancellation.clone());
|
||||
let verification = self.verify_request(request).fuse();
|
||||
pin_mut!(verification);
|
||||
match cancellation {
|
||||
Some(cancellation) => {
|
||||
let cancelled = cancellation.cancelled().fuse();
|
||||
pin_mut!(cancelled);
|
||||
select_biased! {
|
||||
() = cancelled => Err(GatewayError::new(GatewayErrorKind::Cancelled, "client verification was cancelled")),
|
||||
result = verification => result.map_err(authentication_gateway_error),
|
||||
}
|
||||
}
|
||||
None => verification.await.map_err(authentication_gateway_error),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn models(
|
||||
&self,
|
||||
context: BoundModelExecutionContext,
|
||||
client: AuthenticatedClient,
|
||||
cancellation: CancellationToken,
|
||||
protocol: String,
|
||||
client_version: String,
|
||||
) -> BoxFuture<'_, Result<Vec<PublicModelId>, GatewayError>> {
|
||||
Box::pin(async move {
|
||||
let authority = &context.authority;
|
||||
let cancellation = authority.cancellation.cancelled().fuse();
|
||||
let cancelled = cancellation.cancelled().fuse();
|
||||
let timeout = Delay::new(DIAGNOSTIC_TIMEOUT).fuse();
|
||||
let catalog = self
|
||||
.client_model_catalog(&authority.client, &protocol, &client_version)
|
||||
.client_model_catalog(&client, &protocol, &client_version)
|
||||
.fuse();
|
||||
pin_mut!(cancellation, timeout, catalog);
|
||||
pin_mut!(cancelled, timeout, catalog);
|
||||
let models = select_biased! {
|
||||
() = cancellation => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")),
|
||||
() = cancelled => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")),
|
||||
() = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "model catalog deadline elapsed")),
|
||||
result = catalog => result.map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "model catalog is unavailable"))?,
|
||||
};
|
||||
@@ -1754,13 +1767,6 @@ impl NestedModelExecutionPort for DefaultExecutionService {
|
||||
})
|
||||
}
|
||||
|
||||
fn bind(
|
||||
&self,
|
||||
binding: BoundModelExecutionBinding,
|
||||
) -> BoxFuture<'_, Result<BoundModelExecutionContext, GatewayError>> {
|
||||
Box::pin(async move { self.bind_bound_model_inner(binding).await })
|
||||
}
|
||||
|
||||
fn start(
|
||||
&self,
|
||||
request: NestedModelExecutionRequest,
|
||||
|
||||
@@ -62,6 +62,17 @@ impl ExtensionCallScope {
|
||||
}
|
||||
}
|
||||
|
||||
/// 宿主签发的父调用来源;身份裁决、执行链和实际资源仍由各自 owner 持有
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InvocationOrigin {
|
||||
pub request_id: String,
|
||||
pub call_id: String,
|
||||
pub cancellation: crate::lifecycle::CancellationToken,
|
||||
pub extensions: ExtensionCallScope,
|
||||
pub plan: Option<super::middleware::FrozenMiddlewarePlan>,
|
||||
pub effects: Option<Arc<super::nested::ExecutionEffects>>,
|
||||
}
|
||||
|
||||
/// 同一代次的具体执行计划;不拥有发布状态,运行资源由对应发布集合保活
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ExecutionExtensionPlans {
|
||||
|
||||
@@ -19,7 +19,7 @@ use crate::{
|
||||
|
||||
use super::{
|
||||
ModelRequestId,
|
||||
execution::{BoundModelExecutionContext, ExecutionRequestMetadata, StartedExecution},
|
||||
execution::{AuthenticatedClient, ExecutionRequestMetadata, StartedExecution},
|
||||
};
|
||||
|
||||
/// 同一请求图内不可逆外部副作用的单调水位
|
||||
@@ -59,37 +59,14 @@ pub struct NestedModelExecutionRequest {
|
||||
pub parent_account: Option<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// 插件为当前调用或连接显式绑定的模型执行身份
|
||||
///
|
||||
/// Key ID 由插件选择,发起实例由宿主确定
|
||||
/// Core 在绑定时冻结当前
|
||||
/// Key 策略;每个实际模型请求仍独立经过准入、预算、账本与计费
|
||||
pub struct BoundModelExecutionBinding {
|
||||
pub settings: Option<crate::routing::request_settings::RequestSettings>,
|
||||
pub client_key_id: ClientApiKeyId,
|
||||
pub initiating_plugin_instance_id: String,
|
||||
/// 身份随父调用或连接回收;每个模型请求使用独立的执行期限
|
||||
pub cancellation: CancellationToken,
|
||||
/// 宿主保留的扩展调用链,异步观察不能通过新建模型请求清空防递归事实
|
||||
pub extension_scope: super::extensions::ExtensionCallScope,
|
||||
}
|
||||
|
||||
impl fmt::Debug for BoundModelExecutionBinding {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BoundModelExecutionBinding")
|
||||
.field("client_key_id", &self.client_key_id)
|
||||
.field(
|
||||
"initiating_plugin_instance_id",
|
||||
&self.initiating_plugin_instance_id,
|
||||
)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式身份作用域内的一次模型请求
|
||||
///
|
||||
/// 身份与权限可先用于查询;执行链只在真正启动模型请求时建立
|
||||
pub struct BoundModelExecutionRequest {
|
||||
pub context: BoundModelExecutionContext,
|
||||
pub client: AuthenticatedClient,
|
||||
pub initiating_plugin_instance_id: String,
|
||||
pub cancellation: CancellationToken,
|
||||
pub extension_scope: super::extensions::ExtensionCallScope,
|
||||
pub public_model: PublicModelId,
|
||||
pub operation: Operation,
|
||||
pub metadata: ExecutionRequestMetadata,
|
||||
@@ -101,7 +78,11 @@ impl fmt::Debug for BoundModelExecutionRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BoundModelExecutionRequest")
|
||||
.field("context", &self.context)
|
||||
.field("client", &self.client)
|
||||
.field(
|
||||
"initiating_plugin_instance_id",
|
||||
&self.initiating_plugin_instance_id,
|
||||
)
|
||||
.field("public_model", &self.public_model)
|
||||
.field("operation", &self.operation.kind())
|
||||
.field("provider", &self.provider)
|
||||
@@ -130,19 +111,27 @@ impl fmt::Debug for NestedModelExecutionRequest {
|
||||
|
||||
/// 嵌套模型执行只进入 Core 的既有执行会话;Runtime 不实现重试、commit 或计费
|
||||
pub trait NestedModelExecutionPort: Send + Sync {
|
||||
/// 只解析身份与冻结权限,不建立模型执行图或记录使用事实
|
||||
fn resolve_client(
|
||||
&self,
|
||||
client_key_id: ClientApiKeyId,
|
||||
settings: Option<crate::routing::request_settings::RequestSettings>,
|
||||
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>>;
|
||||
|
||||
/// 复用入口认证计划及 principal 映射,不进行模型准入
|
||||
fn verify_client(
|
||||
&self,
|
||||
request: super::authentication::ClientAuthenticationRequest,
|
||||
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>>;
|
||||
|
||||
fn models(
|
||||
&self,
|
||||
context: BoundModelExecutionContext,
|
||||
client: AuthenticatedClient,
|
||||
cancellation: CancellationToken,
|
||||
protocol: String,
|
||||
client_version: String,
|
||||
) -> BoxFuture<'_, Result<Vec<PublicModelId>, GatewayError>>;
|
||||
|
||||
/// 为插件调用冻结显式执行身份;取消父调用会取消其所有模型执行
|
||||
fn bind(
|
||||
&self,
|
||||
binding: BoundModelExecutionBinding,
|
||||
) -> BoxFuture<'_, Result<BoundModelExecutionContext, GatewayError>>;
|
||||
|
||||
fn start(
|
||||
&self,
|
||||
request: NestedModelExecutionRequest,
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
//! 身份、冻结权限与查询不建立模型执行图;实际执行才检查调用链并占用执行资源
|
||||
|
||||
use super::*;
|
||||
use gateway_core::engine::{
|
||||
extensions::{ExtensionCallScope, InvocationOrigin},
|
||||
nested::{BoundModelExecutionRequest, NestedModelExecutionPort},
|
||||
};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
|
||||
#[test]
|
||||
fn identity_queries_keep_frozen_permissions_without_using_execution_resources() {
|
||||
block_on(async {
|
||||
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let usage = Arc::new(RecordingClientApiKeyUsage::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
snapshots.clone(),
|
||||
store.clone(),
|
||||
ProviderRegistry::new([
|
||||
Arc::new(NativeCatalogProvider { fail: false }) as Arc<dyn Provider>
|
||||
])
|
||||
.unwrap(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedContinuation),
|
||||
usage.clone(),
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
);
|
||||
let settings = service.request_settings().unwrap();
|
||||
let key = ClientApiKeyId::new("key_start_test").unwrap();
|
||||
let client =
|
||||
NestedModelExecutionPort::resolve_client(&service, key.clone(), Some(settings.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
snapshots.publish(start_snapshot_with_policy(
|
||||
2,
|
||||
false,
|
||||
RateLimits::unlimited(),
|
||||
FastMode::Disabled,
|
||||
));
|
||||
assert!(
|
||||
client
|
||||
.policy()
|
||||
.account_scope()
|
||||
.allows(&ProviderAccountId::new("acct_start").unwrap(),)
|
||||
);
|
||||
assert!(
|
||||
!client
|
||||
.policy()
|
||||
.account_scope()
|
||||
.allows(&ProviderAccountId::new("acct_missing").unwrap(),)
|
||||
);
|
||||
let models = NestedModelExecutionPort::models(
|
||||
&service,
|
||||
client.clone(),
|
||||
CancellationToken::default(),
|
||||
"codex".into(),
|
||||
"0.154.0".into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(models, vec![PublicModelId::new("gpt-start").unwrap()]);
|
||||
let frozen =
|
||||
NestedModelExecutionPort::resolve_client(&service, key.clone(), Some(settings))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(frozen.snapshot().revision().get(), 1);
|
||||
let current = NestedModelExecutionPort::resolve_client(&service, key, None).await;
|
||||
assert!(matches!(current, Err(error) if error.kind() == GatewayErrorKind::Unauthorized));
|
||||
|
||||
let cancellation = CancellationToken::default();
|
||||
cancellation.cancel();
|
||||
let cancelled = NestedModelExecutionPort::models(
|
||||
&service,
|
||||
client,
|
||||
cancellation,
|
||||
"codex".into(),
|
||||
"0.154.0".into(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(cancelled, Err(error) if error.kind() == GatewayErrorKind::Cancelled));
|
||||
assert!(usage.recorded().is_empty());
|
||||
assert!(!store.touched.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_scope_limits_apply_only_when_starting_a_model() {
|
||||
block_on(async {
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let usage = Arc::new(RecordingClientApiKeyUsage::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(start_snapshot()),
|
||||
store.clone(),
|
||||
ProviderRegistry::new([
|
||||
Arc::new(NativeCatalogProvider { fail: false }) as Arc<dyn Provider>
|
||||
])
|
||||
.unwrap(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedContinuation),
|
||||
usage.clone(),
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
);
|
||||
let repeated = ExtensionCallScope::default()
|
||||
.extending("entry".into())
|
||||
.unwrap();
|
||||
let exhausted = (0..ExtensionCallScope::MAXIMUM_DEPTH)
|
||||
.fold(ExtensionCallScope::default(), |scope, index| {
|
||||
scope.extending(format!("ancestor-{index}")).unwrap()
|
||||
});
|
||||
for extension_scope in [repeated, exhausted] {
|
||||
let client = NestedModelExecutionPort::resolve_client(
|
||||
&service,
|
||||
ClientApiKeyId::new("key_start_test").unwrap(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = NestedModelExecutionPort::start_bound(
|
||||
&service,
|
||||
BoundModelExecutionRequest {
|
||||
client: client.clone(),
|
||||
initiating_plugin_instance_id: "entry".into(),
|
||||
cancellation: CancellationToken::default(),
|
||||
extension_scope,
|
||||
public_model: PublicModelId::new("gpt-start").unwrap(),
|
||||
operation: start_operation(),
|
||||
metadata: ExecutionRequestMetadata {
|
||||
protocol: "openai".into(),
|
||||
endpoint: "host.model".into(),
|
||||
transport: ClientTransport::InternalPlugin,
|
||||
stream: false,
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
previous_response_id: None,
|
||||
},
|
||||
provider: None,
|
||||
account: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(error) if error.kind() == GatewayErrorKind::PolicyDenied));
|
||||
let models = NestedModelExecutionPort::models(
|
||||
&service,
|
||||
client,
|
||||
CancellationToken::default(),
|
||||
"codex".into(),
|
||||
"0.154.0".into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(models, vec![PublicModelId::new("gpt-start").unwrap()]);
|
||||
}
|
||||
assert!(usage.recorded().is_empty());
|
||||
assert!(!store.touched.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
struct ParentAwareAuthentication {
|
||||
calls: AtomicUsize,
|
||||
mapped: bool,
|
||||
}
|
||||
|
||||
impl FrontendAuthenticationPlan for ParentAwareAuthentication {
|
||||
fn authenticate<'a>(
|
||||
&'a self,
|
||||
request: &'a ClientAuthenticationRequest,
|
||||
) -> BoxFuture<'a, Result<FrontendAuthenticationDecision, FrontendAuthenticationError>> {
|
||||
Box::pin(async move {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let origin = request.invocation().expect("parent invocation");
|
||||
assert_eq!(origin.request_id, "http-request");
|
||||
assert_eq!(origin.call_id, "http-call");
|
||||
assert!(origin.extensions.contains("authentication-plugin"));
|
||||
assert!(origin.effects.is_some());
|
||||
assert!(!origin.cancellation.is_cancelled());
|
||||
assert_eq!(request.request_context().unwrap().uri, "/plugin/proxy");
|
||||
assert!(request.settings().is_some());
|
||||
Ok(FrontendAuthenticationDecision::Authenticated {
|
||||
principal: "external-principal".into(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn client_key_id(&self, principal: &str) -> Option<ClientApiKeyId> {
|
||||
assert_eq!(principal, "external-principal");
|
||||
self.mapped
|
||||
.then(|| ClientApiKeyId::new("key_start_test").unwrap())
|
||||
}
|
||||
|
||||
fn exclusive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_keeps_parent_origin_and_requires_the_frontend_principal_mapping() {
|
||||
block_on(async {
|
||||
for mapped in [true, false] {
|
||||
let authentication = FrontendAuthenticationExtensionIndex::default();
|
||||
let plan = Arc::new(ParentAwareAuthentication {
|
||||
calls: AtomicUsize::new(0),
|
||||
mapped,
|
||||
});
|
||||
let snapshot = start_snapshot().with_extensions(Some(authentication_generation(
|
||||
&authentication,
|
||||
"identity-verification",
|
||||
plan.clone(),
|
||||
)));
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let usage = Arc::new(RecordingClientApiKeyUsage::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(snapshot),
|
||||
store.clone(),
|
||||
ProviderRegistry::default(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedContinuation),
|
||||
usage.clone(),
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
)
|
||||
.with_frontend_authentication(authentication);
|
||||
let settings = service.request_settings().unwrap();
|
||||
let origin = InvocationOrigin {
|
||||
request_id: "http-request".into(),
|
||||
call_id: "http-call".into(),
|
||||
cancellation: CancellationToken::default(),
|
||||
extensions: plugin_origin_scope()
|
||||
.extending("authentication-plugin".into())
|
||||
.unwrap(),
|
||||
plan: None,
|
||||
effects: Some(Arc::new(
|
||||
gateway_core::engine::nested::ExecutionEffects::default(),
|
||||
)),
|
||||
};
|
||||
let mut cancelled_origin = origin.clone();
|
||||
cancelled_origin.cancellation = CancellationToken::default();
|
||||
cancelled_origin.cancellation.cancel();
|
||||
let cancelled = NestedModelExecutionPort::verify_client(
|
||||
&service,
|
||||
ClientAuthenticationRequest::new("External fixture")
|
||||
.unwrap()
|
||||
.with_invocation(cancelled_origin)
|
||||
.with_settings(settings.clone()),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(cancelled, Err(error) if error.kind() == GatewayErrorKind::Cancelled));
|
||||
assert_eq!(plan.calls.load(Ordering::SeqCst), 0);
|
||||
let result = NestedModelExecutionPort::verify_client(
|
||||
&service,
|
||||
ClientAuthenticationRequest::new("External fixture")
|
||||
.unwrap()
|
||||
.with_invocation(origin)
|
||||
.with_settings(settings.clone()),
|
||||
)
|
||||
.await;
|
||||
if mapped {
|
||||
let client = result.unwrap();
|
||||
assert_eq!(client.policy().key_id().as_str(), "key_start_test");
|
||||
assert_eq!(client.request_settings(), Some(&settings));
|
||||
} else {
|
||||
assert!(
|
||||
matches!(result, Err(error) if error.kind() == GatewayErrorKind::Unauthorized)
|
||||
);
|
||||
}
|
||||
assert_eq!(plan.calls.load(Ordering::SeqCst), 1);
|
||||
assert!(usage.recorded().is_empty());
|
||||
assert!(!store.touched.load(Ordering::SeqCst));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 执行服务的准入、预算、请求记录与终结行为测试
|
||||
|
||||
mod identity;
|
||||
mod outbound;
|
||||
use gateway_core::routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference};
|
||||
|
||||
@@ -1215,9 +1216,7 @@ fn plugin_origin_scope() -> gateway_core::engine::extensions::ExtensionCallScope
|
||||
|
||||
#[test]
|
||||
fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_origin_unknown() {
|
||||
use gateway_core::engine::nested::{
|
||||
BoundModelExecutionBinding, BoundModelExecutionRequest, NestedModelExecutionPort,
|
||||
};
|
||||
use gateway_core::engine::nested::{BoundModelExecutionRequest, NestedModelExecutionPort};
|
||||
block_on(async {
|
||||
for has_origin in [true, false] {
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
@@ -1234,11 +1233,17 @@ fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_orig
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
);
|
||||
let input = request(&service, ClientTransport::InternalPlugin);
|
||||
let context = NestedModelExecutionPort::bind(
|
||||
let client = NestedModelExecutionPort::resolve_client(
|
||||
&service,
|
||||
BoundModelExecutionBinding {
|
||||
settings: None,
|
||||
client_key_id: ClientApiKeyId::new("key_start_test").unwrap(),
|
||||
ClientApiKeyId::new("key_start_test").unwrap(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut started = NestedModelExecutionPort::start_bound(
|
||||
&service,
|
||||
BoundModelExecutionRequest {
|
||||
client,
|
||||
initiating_plugin_instance_id: "origin-fixture".into(),
|
||||
cancellation: Default::default(),
|
||||
extension_scope: if has_origin {
|
||||
@@ -1246,14 +1251,6 @@ fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_orig
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut started = NestedModelExecutionPort::start_bound(
|
||||
&service,
|
||||
BoundModelExecutionRequest {
|
||||
context: context.clone(),
|
||||
public_model: input.public_model,
|
||||
operation: input.operation,
|
||||
metadata: input.metadata,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"manifest_schema_versions": [2],
|
||||
"protocol_versions": [6],
|
||||
"protocol_versions": [7],
|
||||
"capabilities": [
|
||||
{ "capability": "frontend_authentication", "versions": [2] },
|
||||
{ "capability": "scheduler", "versions": [3] },
|
||||
|
||||
@@ -195,21 +195,29 @@ impl FrontendAuthenticationPlan for PluginFrontendAuthenticationPlan {
|
||||
let mut context =
|
||||
session.context(Stage::Authentication, session.budget(Stage::Authentication));
|
||||
context.request = crate::adapter::request_context(request.request_context());
|
||||
context.request_id = request.invocation().map(|origin| origin.request_id.clone());
|
||||
let _scope = callbacks
|
||||
.prepare_frontend_authentication(&context, request.shared_request_context())
|
||||
.prepare_frontend_authentication(&context, request)
|
||||
.map_err(|_| FrontendAuthenticationError)?;
|
||||
let input = FrontendAuthenticationRequest {
|
||||
authorization: request.authorization().to_owned(),
|
||||
};
|
||||
let reply = session
|
||||
.call(
|
||||
"frontend_auth.authenticate",
|
||||
context,
|
||||
serde_json::json!({}),
|
||||
serde_json::to_vec(&input).map_err(|_| FrontendAuthenticationError)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| FrontendAuthenticationError)?;
|
||||
let invocation = session.call(
|
||||
"frontend_auth.authenticate",
|
||||
context,
|
||||
serde_json::json!({}),
|
||||
serde_json::to_vec(&input).map_err(|_| FrontendAuthenticationError)?,
|
||||
);
|
||||
let reply = if let Some(origin) = request.invocation() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = origin.cancellation.cancelled() => return Err(FrontendAuthenticationError),
|
||||
reply = invocation => reply,
|
||||
}
|
||||
} else {
|
||||
invocation.await
|
||||
}
|
||||
.map_err(|_| FrontendAuthenticationError)?;
|
||||
let result: FrontendAuthenticationResult =
|
||||
serde_json::from_slice(&reply.payload).map_err(|_| FrontendAuthenticationError)?;
|
||||
match result {
|
||||
|
||||
@@ -51,7 +51,9 @@ pub(super) async fn invoke(
|
||||
request_id: context.request_id.clone(),
|
||||
call_id: context.call_id.clone(),
|
||||
cancellation: context.cancellation.clone(),
|
||||
plan,
|
||||
plan: Some(plan),
|
||||
extensions: context.extensions.clone(),
|
||||
effects: None,
|
||||
}),
|
||||
context.extensions.clone(),
|
||||
invocation.clone(),
|
||||
|
||||
@@ -38,7 +38,9 @@ pub(super) async fn invoke(
|
||||
request_id: context.request_id.clone(),
|
||||
call_id: context.call_id.clone(),
|
||||
cancellation: context.cancellation.clone(),
|
||||
plan: context.plan.clone(),
|
||||
plan: Some(context.plan.clone()),
|
||||
extensions: context.extensions.clone(),
|
||||
effects: None,
|
||||
}),
|
||||
context.extensions.clone(),
|
||||
invocation,
|
||||
|
||||
@@ -56,7 +56,9 @@ pub(super) async fn invoke(
|
||||
request_id: context.connection_id.clone(),
|
||||
call_id: call.resource_scope_id.clone(),
|
||||
cancellation: context.cancellation.clone(),
|
||||
plan,
|
||||
plan: Some(plan),
|
||||
extensions: context.extensions.clone(),
|
||||
effects: None,
|
||||
}),
|
||||
context.extensions,
|
||||
invocation.clone(),
|
||||
|
||||
@@ -66,6 +66,32 @@ impl PluginAccounts {
|
||||
Self { slot }
|
||||
}
|
||||
|
||||
pub(super) async fn http_proxy(
|
||||
&self,
|
||||
account_id: String,
|
||||
credential_revision: u64,
|
||||
) -> Result<Option<gateway_core::account::OutboundProxy>, PluginFault> {
|
||||
let id = ProviderAccountId::new(account_id).map_err(|_| invalid())?;
|
||||
let expected = Revision::new(credential_revision).map_err(|_| invalid())?;
|
||||
let account = self
|
||||
.slot
|
||||
.upgrade()
|
||||
.map_err(map_admin_error)?
|
||||
.get_runtime(&id)
|
||||
.await
|
||||
.map_err(map_admin_error)?;
|
||||
if !account.enabled {
|
||||
return Err(denied());
|
||||
}
|
||||
if account.credential_revision != expected {
|
||||
return Err(PluginFault::new(
|
||||
ErrorCode::Conflict,
|
||||
"account credential revision has changed",
|
||||
));
|
||||
}
|
||||
Ok(account.effective_outbound_proxy().cloned())
|
||||
}
|
||||
|
||||
pub(super) async fn call(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
|
||||
@@ -16,9 +16,8 @@ pub(super) struct CallResources {
|
||||
pub(super) cancellation: CancellationToken,
|
||||
pub(super) scope: Arc<CallbackScope>,
|
||||
pub(super) http_resources: Arc<http_middleware::resources::Resources>,
|
||||
pub(super) model_bindings: tokio::sync::Mutex<
|
||||
BTreeMap<String, gateway_core::engine::execution::BoundModelExecutionContext>,
|
||||
>,
|
||||
pub(super) client_identities:
|
||||
tokio::sync::Mutex<BTreeMap<String, gateway_core::engine::execution::AuthenticatedClient>>,
|
||||
pub(super) state: Mutex<CallState>,
|
||||
}
|
||||
|
||||
@@ -32,13 +31,18 @@ impl CallResources {
|
||||
Self {
|
||||
deadline: deadline.clone(),
|
||||
operation_timeout: Duration::from_millis(context.timeout_ms),
|
||||
cancellation: CancellationToken::new(),
|
||||
cancellation: scope
|
||||
.origin
|
||||
.as_ref()
|
||||
.map_or_else(CancellationToken::new, |origin| {
|
||||
origin.cancellation.child_token()
|
||||
}),
|
||||
scope,
|
||||
http_resources: middleware
|
||||
.as_ref()
|
||||
.and_then(|middleware| middleware.http_resources())
|
||||
.unwrap_or_default(),
|
||||
model_bindings: tokio::sync::Mutex::new(BTreeMap::new()),
|
||||
client_identities: tokio::sync::Mutex::new(BTreeMap::new()),
|
||||
state: Mutex::new(CallState {
|
||||
middleware,
|
||||
..CallState::default()
|
||||
@@ -46,6 +50,40 @@ impl CallResources {
|
||||
}
|
||||
}
|
||||
|
||||
/// 子调用共享受信来源;身份解析与执行准入仍留在各能力 owner
|
||||
pub(super) fn child_origin(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
) -> Result<gateway_core::engine::extensions::InvocationOrigin, PluginFault> {
|
||||
let parent = self.scope.origin.as_ref();
|
||||
Ok(gateway_core::engine::extensions::InvocationOrigin {
|
||||
request_id: parent.map_or_else(
|
||||
|| {
|
||||
context
|
||||
.request_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| context.resource_scope_id.clone())
|
||||
},
|
||||
|origin| origin.request_id.clone(),
|
||||
),
|
||||
call_id: parent.map_or_else(
|
||||
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|
||||
|origin| origin.call_id.clone(),
|
||||
),
|
||||
cancellation: self.cancellation.child_token(),
|
||||
extensions: if self.scope.extension_scope.contains(&context.instance_id) {
|
||||
self.scope.extension_scope.clone()
|
||||
} else {
|
||||
self.scope
|
||||
.extension_scope
|
||||
.extending(context.instance_id.clone())
|
||||
.ok_or_else(super::invalid)?
|
||||
},
|
||||
plan: parent.and_then(|origin| origin.plan.clone()),
|
||||
effects: self.scope.execution_effects.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn close(&self) {
|
||||
self.cancellation.cancel();
|
||||
let mut state = self
|
||||
@@ -114,7 +152,9 @@ impl CallResources {
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.middleware
|
||||
.clone();
|
||||
middleware.and_then(|middleware| middleware.request_settings())
|
||||
middleware
|
||||
.and_then(|middleware| middleware.request_settings())
|
||||
.or_else(|| self.scope.settings.clone())
|
||||
}
|
||||
|
||||
/// 建立受管流后,网络操作各自计时;连接空闲时间不消耗后续操作的预算
|
||||
|
||||
@@ -98,7 +98,7 @@ impl PluginData {
|
||||
next_cursor: page.next_cursor.map(|id| id.as_str().to_owned()),
|
||||
})
|
||||
}
|
||||
data::QUOTA_GET | data::QUOTA_REFRESH => {
|
||||
data::QUOTA_GET | data::QUOTA_REFRESH | data::QUOTA_DETAILS => {
|
||||
let accounts = self.accounts.upgrade().map_err(map_admin_error)?;
|
||||
let query: data::QuotaFactsQuery =
|
||||
serde_json::from_slice(payload).map_err(|_| invalid())?;
|
||||
@@ -109,6 +109,35 @@ impl PluginData {
|
||||
accounts.get_quota(&account_id).await
|
||||
}
|
||||
.map_err(map_admin_error)?;
|
||||
if method == data::QUOTA_DETAILS {
|
||||
return super::admin::encode(&data::QuotaDetails {
|
||||
schema_version: 1,
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
observed_at_ms: quota.observed_at.map(|time| time.timestamp_millis()),
|
||||
limit_reached: quota.limit_reached,
|
||||
windows: quota
|
||||
.windows
|
||||
.into_iter()
|
||||
.map(|window| data::QuotaWindowDetails {
|
||||
key: window.key,
|
||||
group: window.group,
|
||||
label: window.label,
|
||||
limit_id: window.limit_id,
|
||||
limit_name: window.limit_name,
|
||||
role: window.role.map(|role| role.as_str().to_owned()),
|
||||
window_seconds: window.window_seconds,
|
||||
used_percent: window.used_percent,
|
||||
reset_at_ms: window.reset_at.map(|time| time.timestamp_millis()),
|
||||
limit_reached: window.limit_reached,
|
||||
})
|
||||
.collect(),
|
||||
credits: quota.credits.map(|credits| data::QuotaCredits {
|
||||
has_credits: credits.has_credits,
|
||||
unlimited: credits.unlimited,
|
||||
balance: credits.balance,
|
||||
}),
|
||||
});
|
||||
}
|
||||
serde_json::to_vec(&data::QuotaFacts {
|
||||
schema_version: 1,
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
|
||||
@@ -29,16 +29,42 @@ impl HttpCallbacks<'_> {
|
||||
self.request_limits.body_bytes(self.call)
|
||||
}
|
||||
|
||||
pub(super) async fn account(
|
||||
&self,
|
||||
accounts: &super::accounts::PluginAccounts,
|
||||
params: serde_json::Value,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<RpcReply, PluginFault> {
|
||||
let request: wire::AccountHttpRequest = decode(params)?;
|
||||
let maximum_body_bytes = self.body_limit(&payload)?;
|
||||
let proxy = accounts
|
||||
.http_proxy(request.account_id, request.credential_revision)
|
||||
.await?;
|
||||
self.send(
|
||||
HttpRequest {
|
||||
method: request.request.method,
|
||||
url: request.request.url,
|
||||
headers: request.request.headers,
|
||||
body: payload,
|
||||
maximum_body_bytes,
|
||||
},
|
||||
true,
|
||||
None,
|
||||
proxy.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn send(
|
||||
&self,
|
||||
request: HttpRequest,
|
||||
streaming: bool,
|
||||
upstream_purpose: Option<gateway_plugin_sdk::call::upstream_adapter::UpstreamPathPurpose>,
|
||||
proxy: Option<&gateway_core::account::OutboundProxy>,
|
||||
) -> Result<RpcReply, PluginFault> {
|
||||
let Self {
|
||||
client,
|
||||
network,
|
||||
scope,
|
||||
call,
|
||||
maximum_payload,
|
||||
..
|
||||
@@ -48,9 +74,7 @@ impl HttpCallbacks<'_> {
|
||||
}
|
||||
let timeout = call.timeout()?;
|
||||
let attempt = call.start_upstream(upstream_purpose)?;
|
||||
let response = client
|
||||
.open(request, scope.proxy.as_ref(), network, timeout)
|
||||
.await;
|
||||
let response = client.open(request, proxy, network, timeout).await;
|
||||
attempt.finish(
|
||||
response
|
||||
.as_ref()
|
||||
@@ -127,6 +151,7 @@ impl HttpCallbacks<'_> {
|
||||
},
|
||||
method == "host.http.do_stream",
|
||||
None,
|
||||
self.scope.proxy.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -36,25 +36,23 @@ pub(super) async fn call(
|
||||
request.timeout_ms,
|
||||
&context.instance_id,
|
||||
)?;
|
||||
let parent = call.scope.origin.as_ref();
|
||||
call.scope.check_child_depth(&context.instance_id)?;
|
||||
let origin = call.child_origin(context)?;
|
||||
let child = core::Context {
|
||||
plugin_instance_id: Some(context.instance_id.clone()),
|
||||
request_id: context
|
||||
.request_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| context.resource_scope_id.clone()),
|
||||
request_id: origin.request_id,
|
||||
call_id: uuid::Uuid::now_v7().to_string(),
|
||||
parent_call_id: Some(parent.map_or_else(
|
||||
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|
||||
|parent| parent.call_id.clone(),
|
||||
)),
|
||||
extensions: call.scope.child_extensions(&context.instance_id)?,
|
||||
// 句柄可以在父 RPC End 后交给 HTTP 发送端;生命周期属于父调用和响应正文,不能在 RPC End 提前取消
|
||||
cancellation: parent.map_or_else(
|
||||
|| call.cancellation.child_token(),
|
||||
|parent| parent.cancellation.child_token(),
|
||||
),
|
||||
plan: parent.map(|parent| parent.plan.clone()),
|
||||
parent_call_id: Some(origin.call_id),
|
||||
extensions: origin.extensions,
|
||||
// 转交正文后由消费端持有原请求寿命,父 RPC End 只撤销新业务分派
|
||||
cancellation: call
|
||||
.scope
|
||||
.origin
|
||||
.as_ref()
|
||||
.map_or(origin.cancellation, |parent| {
|
||||
parent.cancellation.child_token()
|
||||
}),
|
||||
plan: origin.plan,
|
||||
};
|
||||
let body = call.http_resources.body(request.body, payload).await?;
|
||||
let mut http = core::Request::new(body);
|
||||
|
||||
@@ -221,7 +221,9 @@ impl PluginCallbacks {
|
||||
.clone()
|
||||
.unwrap_or_else(|| context.resource_scope_id.clone()),
|
||||
cancellation,
|
||||
plan,
|
||||
extensions: scope.extension_scope.clone(),
|
||||
effects: scope.execution_effects.clone(),
|
||||
plan: Some(plan),
|
||||
});
|
||||
self.register_scope(context, scope)
|
||||
}
|
||||
@@ -278,7 +280,7 @@ impl PluginCallbacks {
|
||||
pub(crate) fn prepare_frontend_authentication(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
|
||||
request: &gateway_core::engine::authentication::ClientAuthenticationRequest,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
if context.stage != gateway_plugin_sdk::Stage::Authentication
|
||||
|| context.account_id.is_some()
|
||||
@@ -287,7 +289,16 @@ impl PluginCallbacks {
|
||||
{
|
||||
return Err(denied());
|
||||
}
|
||||
self.prepare_scope(context, None, request)
|
||||
let mut scope = CallbackScope::for_call(context);
|
||||
scope.settings = request.settings().cloned();
|
||||
if let Some(origin) = request.invocation() {
|
||||
scope.extension_scope = origin.extensions.clone();
|
||||
scope.execution_effects = origin.effects.clone();
|
||||
scope.origin = Some(origin.clone());
|
||||
} else if let Some(request) = request.shared_request_context() {
|
||||
scope.extension_scope = scope.extension_scope.with_request(request);
|
||||
}
|
||||
self.register_scope(context, scope)
|
||||
}
|
||||
|
||||
pub(crate) async fn save_command_account(
|
||||
@@ -500,7 +511,12 @@ impl CallbackHandler for PluginCallbacks {
|
||||
) {
|
||||
return keys.call(&context, &method, params, &payload).await;
|
||||
}
|
||||
if method == "host.models.list" {
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
"host.models.list"
|
||||
| gateway_plugin_sdk::call::data::CLIENT_VERIFY
|
||||
| gateway_plugin_sdk::call::data::ACCESS_CHECK
|
||||
) {
|
||||
return models.call(&context, &call, &method, params, payload).await;
|
||||
}
|
||||
if matches!(
|
||||
@@ -524,6 +540,7 @@ impl CallbackHandler for PluginCallbacks {
|
||||
| gateway_plugin_sdk::call::data::KEYS_GET
|
||||
| gateway_plugin_sdk::call::data::QUOTA_GET
|
||||
| gateway_plugin_sdk::call::data::QUOTA_REFRESH
|
||||
| gateway_plugin_sdk::call::data::QUOTA_DETAILS
|
||||
) {
|
||||
return data.call(&method, params, &payload).await;
|
||||
}
|
||||
@@ -543,6 +560,9 @@ impl CallbackHandler for PluginCallbacks {
|
||||
maximum_payload,
|
||||
request_limits: &request_limits,
|
||||
};
|
||||
if method == gateway_plugin_sdk::call::host::ACCOUNT_HTTP {
|
||||
return http.account(&accounts, params, payload).await;
|
||||
}
|
||||
if method.starts_with("host.upstream.") {
|
||||
return upstream::dispatch(&http, &method, params, payload).await;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! 嵌套模型执行;只驱动 Core 会话,不拥有路由、重试或计费规则
|
||||
//! 复用 Core 的身份查询与模型执行入口;不拥有权限、路由、重试或计费规则
|
||||
|
||||
use std::sync::{Arc, OnceLock, Weak};
|
||||
|
||||
@@ -8,12 +8,12 @@ use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
engine::{
|
||||
CommitRequirement, ModelRequestId,
|
||||
authentication::ClientAuthenticationRequest,
|
||||
execution::{
|
||||
BoundModelExecutionContext, ClientTransport, ExecutionRequestMetadata, ExecutionSession,
|
||||
AuthenticatedClient, ClientTransport, ExecutionRequestMetadata, ExecutionSession,
|
||||
},
|
||||
nested::{
|
||||
BoundModelExecutionBinding, BoundModelExecutionRequest, NestedModelExecutionPort,
|
||||
NestedModelExecutionRequest,
|
||||
BoundModelExecutionRequest, NestedModelExecutionPort, NestedModelExecutionRequest,
|
||||
},
|
||||
},
|
||||
event::ProviderEvent,
|
||||
@@ -23,10 +23,11 @@ use gateway_core::{
|
||||
RawJsonPayload, StandaloneSearchRequest,
|
||||
},
|
||||
policy::ClientApiKeyId,
|
||||
routing::PublicModelId,
|
||||
routing::{PublicModelId, UpstreamModelId},
|
||||
};
|
||||
use gateway_plugin_sdk::{
|
||||
CallContext, ErrorCode, PluginFault,
|
||||
call::data,
|
||||
call::host::{
|
||||
ModelEventBatch, ModelExecuteRequest, ModelExecuteResult, ModelListRequest,
|
||||
ModelListResult, ModelOperation, ModelStreamCloseRequest, ModelStreamReadRequest,
|
||||
@@ -38,6 +39,7 @@ use super::{CallResources, denied, invalid};
|
||||
use crate::RpcReply;
|
||||
|
||||
const MAX_MODEL_STREAMS_PER_CALL: usize = 16;
|
||||
const MAX_CLIENT_IDENTITIES_PER_CALL: usize = 16;
|
||||
|
||||
pub(crate) struct PluginModelPortSlot {
|
||||
port: OnceLock<Weak<dyn NestedModelExecutionPort>>,
|
||||
@@ -89,6 +91,64 @@ impl PluginModels {
|
||||
payload: Vec<u8>,
|
||||
) -> Result<RpcReply, PluginFault> {
|
||||
match method {
|
||||
data::CLIENT_VERIFY => {
|
||||
if params != serde_json::json!({}) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let request: data::VerifyClientRequest =
|
||||
serde_json::from_slice(&payload).map_err(|_| invalid())?;
|
||||
let mut authentication = ClientAuthenticationRequest::new(request.authorization)
|
||||
.map_err(|_| invalid())?
|
||||
.with_invocation(call.child_origin(context)?);
|
||||
if let Some(settings) = call.request_settings() {
|
||||
authentication = authentication.with_settings(settings);
|
||||
}
|
||||
let port = self.slot.upgrade()?;
|
||||
call.observe_dispatch()?;
|
||||
let client = port
|
||||
.verify_client(authentication)
|
||||
.await
|
||||
.map_err(super::error::gateway)?;
|
||||
super::admin::encode(&data::VerifiedClient {
|
||||
client_key_id: client.policy().key_id().as_str().to_owned(),
|
||||
})
|
||||
}
|
||||
data::ACCESS_CHECK => {
|
||||
if params != serde_json::json!({}) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let request: data::AccessQuery =
|
||||
serde_json::from_slice(&payload).map_err(|_| invalid())?;
|
||||
let port = self.slot.upgrade()?;
|
||||
let client = self.identity(&port, call, request.client_key_id).await?;
|
||||
let scope = client.policy().account_scope();
|
||||
let model = request
|
||||
.upstream_model
|
||||
.map(UpstreamModelId::new)
|
||||
.transpose()
|
||||
.map_err(|_| invalid())?;
|
||||
let accounts = request
|
||||
.account_ids
|
||||
.into_iter()
|
||||
.map(|account_id| {
|
||||
let account =
|
||||
ProviderAccountId::new(account_id.clone()).map_err(|_| invalid())?;
|
||||
let allowed = model.as_ref().map_or_else(
|
||||
|| scope.allows(&account),
|
||||
|model| scope.allows_model(&account, model.as_str()),
|
||||
);
|
||||
Ok(data::AccountAccessFacts {
|
||||
account_id,
|
||||
allowed,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, PluginFault>>()?;
|
||||
super::admin::encode(&data::AccessFacts {
|
||||
schema_version: 1,
|
||||
client_key_id: client.policy().key_id().as_str().to_owned(),
|
||||
accounts,
|
||||
})
|
||||
}
|
||||
"host.models.list" => {
|
||||
if !payload.is_empty() {
|
||||
return Err(invalid());
|
||||
@@ -102,19 +162,14 @@ impl PluginModels {
|
||||
return Err(invalid());
|
||||
}
|
||||
let port = self.slot.upgrade()?;
|
||||
let bound = port
|
||||
.bind(BoundModelExecutionBinding {
|
||||
settings: call.request_settings(),
|
||||
client_key_id: ClientApiKeyId::new(request.client_key_id)
|
||||
.map_err(|_| invalid())?,
|
||||
initiating_plugin_instance_id: context.instance_id.clone(),
|
||||
cancellation: call.cancellation.child_token(),
|
||||
extension_scope: call.scope.extension_scope.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(super::error::gateway)?;
|
||||
let client = self.identity(&port, call, request.client_key_id).await?;
|
||||
let models = port
|
||||
.models(bound, request.protocol, request.client_version)
|
||||
.models(
|
||||
client,
|
||||
call.cancellation.clone(),
|
||||
request.protocol,
|
||||
request.client_version,
|
||||
)
|
||||
.await
|
||||
.map_err(super::error::gateway)?;
|
||||
encode_result(ModelListResult {
|
||||
@@ -141,9 +196,12 @@ impl PluginModels {
|
||||
let port = self.slot.upgrade()?;
|
||||
let stream = method == "host.model.execute_stream";
|
||||
let started = if let Some(client_key_id) = request.client_key_id.take() {
|
||||
let bound = self.binding(&port, context, call, client_key_id).await?;
|
||||
check_size(bound.request_body_limit())?;
|
||||
port.start_bound(self.bound_request(request, payload, stream, bound)?)
|
||||
let client = self.identity(&port, call, client_key_id).await?;
|
||||
check_size(client.snapshot().responses_max_decompressed_body_bytes())?;
|
||||
let request = self.request_parts(request, payload, stream)?;
|
||||
let request = Self::bound_request(context, call, request, client);
|
||||
call.observe_dispatch()?;
|
||||
port.start_bound(request)
|
||||
.await
|
||||
.map_err(super::error::gateway)?
|
||||
} else {
|
||||
@@ -237,42 +295,36 @@ impl PluginModels {
|
||||
}
|
||||
}
|
||||
|
||||
async fn binding(
|
||||
async fn identity(
|
||||
&self,
|
||||
port: &Arc<dyn NestedModelExecutionPort>,
|
||||
context: &CallContext,
|
||||
call: &CallResources,
|
||||
client_key_id: String,
|
||||
) -> Result<BoundModelExecutionContext, PluginFault> {
|
||||
) -> Result<AuthenticatedClient, PluginFault> {
|
||||
let key = ClientApiKeyId::new(client_key_id.clone()).map_err(|_| invalid())?;
|
||||
let settings = call.request_settings();
|
||||
let mut bindings = call.model_bindings.lock().await;
|
||||
if let Some(bound) = bindings
|
||||
let mut identities = call.client_identities.lock().await;
|
||||
if let Some(client) = identities
|
||||
.get(&client_key_id)
|
||||
.filter(|bound| bound.request_settings() == settings.as_ref())
|
||||
.filter(|client| client.request_settings() == settings.as_ref())
|
||||
{
|
||||
return Ok(bound.clone());
|
||||
return Ok(client.clone());
|
||||
}
|
||||
if !bindings.contains_key(&client_key_id) && bindings.len() >= MAX_MODEL_STREAMS_PER_CALL {
|
||||
if !identities.contains_key(&client_key_id)
|
||||
&& identities.len() >= MAX_CLIENT_IDENTITIES_PER_CALL
|
||||
{
|
||||
return Err(PluginFault::new(
|
||||
ErrorCode::Capacity,
|
||||
"model identity capacity is exhausted",
|
||||
"client identity capacity is exhausted",
|
||||
));
|
||||
}
|
||||
let bound = port
|
||||
.bind(BoundModelExecutionBinding {
|
||||
settings,
|
||||
client_key_id: key,
|
||||
initiating_plugin_instance_id: context.instance_id.clone(),
|
||||
cancellation: call.cancellation.child_token(),
|
||||
extension_scope: call.scope.extension_scope.clone(),
|
||||
})
|
||||
let client = port
|
||||
.resolve_client(key, settings)
|
||||
.await
|
||||
.map_err(super::error::gateway)?;
|
||||
// 调用持有身份直到 RPC 结束,流式执行不会因单次 callback 返回而提前取消;
|
||||
// 同一 Key 复用身份快照,每次执行由 Core 创建独立调用图
|
||||
bindings.insert(client_key_id, bound.clone());
|
||||
Ok(bound)
|
||||
// 查询与执行共用调用内快照,执行链和子执行取消只在真正启动时建立
|
||||
identities.insert(client_key_id, client.clone());
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
fn request(
|
||||
@@ -310,21 +362,22 @@ impl PluginModels {
|
||||
}
|
||||
|
||||
fn bound_request(
|
||||
&self,
|
||||
request: ModelExecuteRequest,
|
||||
body: Vec<u8>,
|
||||
stream: bool,
|
||||
context: BoundModelExecutionContext,
|
||||
) -> Result<BoundModelExecutionRequest, PluginFault> {
|
||||
let request = self.request_parts(request, body, stream)?;
|
||||
Ok(BoundModelExecutionRequest {
|
||||
context,
|
||||
context: &CallContext,
|
||||
call: &CallResources,
|
||||
request: ModelRequestParts,
|
||||
client: AuthenticatedClient,
|
||||
) -> BoundModelExecutionRequest {
|
||||
BoundModelExecutionRequest {
|
||||
client,
|
||||
initiating_plugin_instance_id: context.instance_id.clone(),
|
||||
cancellation: call.cancellation.clone(),
|
||||
extension_scope: call.scope.extension_scope.clone(),
|
||||
public_model: request.public_model,
|
||||
operation: request.operation,
|
||||
metadata: request.metadata,
|
||||
provider: request.provider,
|
||||
account: request.account,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn request_parts(
|
||||
|
||||
@@ -7,14 +7,7 @@ use gateway_core::{
|
||||
};
|
||||
use gateway_plugin_sdk::{CallContext, Stage};
|
||||
|
||||
/// 所有中间件共享的父调用身份;不混入协议操作或服务参数
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct InvocationContext {
|
||||
pub request_id: String,
|
||||
pub call_id: String,
|
||||
pub cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
pub plan: gateway_core::engine::middleware::FrozenMiddlewarePlan,
|
||||
}
|
||||
pub(crate) use gateway_core::engine::extensions::InvocationOrigin as InvocationContext;
|
||||
|
||||
/// 一次回调的调用链、账号与网络事实,由操作持有到完成
|
||||
pub(crate) struct CallbackScope {
|
||||
@@ -25,7 +18,8 @@ pub(crate) struct CallbackScope {
|
||||
credential_revision: Option<u64>,
|
||||
pub(super) proxy: Option<OutboundProxy>,
|
||||
pub(super) extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
execution_effects: Option<Arc<ExecutionEffects>>,
|
||||
pub(super) execution_effects: Option<Arc<ExecutionEffects>>,
|
||||
pub(super) settings: Option<gateway_core::routing::request_settings::RequestSettings>,
|
||||
pub(super) upstream: Option<Arc<super::upstream::ManagedUpstream>>,
|
||||
}
|
||||
|
||||
@@ -37,14 +31,13 @@ impl CallbackScope {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn child_extensions(
|
||||
pub(super) fn check_child_depth(
|
||||
&self,
|
||||
instance_id: &str,
|
||||
) -> Result<gateway_core::engine::extensions::ExtensionCallScope, gateway_plugin_sdk::PluginFault>
|
||||
{
|
||||
) -> Result<(), gateway_plugin_sdk::PluginFault> {
|
||||
// 上游适配器在进入回调前已登记当前实例;主动子调用沿用该集合
|
||||
if self.extension_scope.contains(instance_id) {
|
||||
return Ok(self.extension_scope.clone());
|
||||
return Ok(());
|
||||
}
|
||||
if self.extension_scope.len()
|
||||
>= gateway_core::engine::extensions::ExtensionCallScope::MAXIMUM_DEPTH
|
||||
@@ -54,9 +47,7 @@ impl CallbackScope {
|
||||
"child call depth exceeded",
|
||||
));
|
||||
}
|
||||
self.extension_scope
|
||||
.extending(instance_id.to_owned())
|
||||
.ok_or_else(super::invalid)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn new(context: &CallContext, proxy: Option<OutboundProxy>) -> Self {
|
||||
@@ -69,6 +60,7 @@ impl CallbackScope {
|
||||
proxy,
|
||||
extension_scope: Default::default(),
|
||||
execution_effects: None,
|
||||
settings: None,
|
||||
upstream: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,27 +63,18 @@ impl ServicePorts {
|
||||
)
|
||||
})?;
|
||||
let call = registry.call(&request.operation, request.input);
|
||||
let scope = &resources.scope;
|
||||
let origin = scope.origin.as_ref();
|
||||
resources.scope.check_child_depth(&context.instance_id)?;
|
||||
let origin = resources.child_origin(context)?;
|
||||
let parent = gateway_admin::public_service::Origin {
|
||||
request_id: origin.map_or_else(
|
||||
|| {
|
||||
context
|
||||
.request_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| context.resource_scope_id.clone())
|
||||
},
|
||||
|origin| origin.request_id.clone(),
|
||||
),
|
||||
call_id: origin.map_or_else(
|
||||
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|
||||
|origin| origin.call_id.clone(),
|
||||
),
|
||||
cancellation: resources.cancellation.clone(),
|
||||
extensions: scope.child_extensions(&context.instance_id)?,
|
||||
plan: origin.map_or(gateway_admin::public_service::Plan::Current, |origin| {
|
||||
gateway_admin::public_service::Plan::Frozen(Some(origin.plan.clone()))
|
||||
}),
|
||||
request_id: origin.request_id,
|
||||
call_id: origin.call_id,
|
||||
cancellation: origin.cancellation,
|
||||
extensions: origin.extensions,
|
||||
plan: origin
|
||||
.plan
|
||||
.map_or(gateway_admin::public_service::Plan::Current, |plan| {
|
||||
gateway_admin::public_service::Plan::Frozen(Some(plan))
|
||||
}),
|
||||
};
|
||||
// 即使终端只读,服务中间件也可提交写入;不存在的操作不会进入调用链
|
||||
if registry
|
||||
|
||||
@@ -197,8 +197,13 @@ pub(super) async fn dispatch(
|
||||
let request = serde_json::from_value(params).map_err(|_| invalid())?;
|
||||
let maximum_body_bytes = http.body_limit(&payload)?;
|
||||
let (request, purpose) = managed.request(request, payload, maximum_body_bytes)?;
|
||||
http.send(request, method.ends_with("do_stream"), Some(purpose))
|
||||
.await
|
||||
http.send(
|
||||
request,
|
||||
method.ends_with("do_stream"),
|
||||
Some(purpose),
|
||||
scope.proxy.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"host.upstream.http.stream_read" | "host.upstream.http.stream_close" => {
|
||||
http.dispatch(
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::sync::Arc;
|
||||
|
||||
use gateway_plugin_sdk::{
|
||||
ErrorCode, Frame, Message, PluginFault,
|
||||
call::middleware::NEXT_METHOD,
|
||||
client::{EncodedFrame, read_frame},
|
||||
};
|
||||
use tokio::{
|
||||
@@ -100,6 +99,13 @@ async fn read_loop<R: AsyncRead + Unpin>(
|
||||
}
|
||||
};
|
||||
let result = match frame.message {
|
||||
Message::Suspend { id, epoch } if frame.payload.is_empty() => shared.suspend(id, epoch),
|
||||
Message::Resume { id, epoch } if frame.payload.is_empty() => {
|
||||
shared.request_resume(id, epoch)
|
||||
}
|
||||
Message::CallbackConsumed { id, parent_id } if frame.payload.is_empty() => {
|
||||
shared.consume_callback(parent_id, id)
|
||||
}
|
||||
Message::Cancelled { id } if frame.payload.is_empty() => {
|
||||
shared.acknowledge_cancellation(id)
|
||||
}
|
||||
@@ -125,26 +131,17 @@ async fn read_loop<R: AsyncRead + Unpin>(
|
||||
Err(RpcError::Protocol(None))
|
||||
} else if let Some(context) = shared.context(parent_id) {
|
||||
last_callback = id;
|
||||
if let Some(permit) = shared.try_callback_slot(&method) {
|
||||
if let Some(permit) = shared.try_callback_slot() {
|
||||
let handler = Arc::clone(&callbacks);
|
||||
let response = Arc::clone(&shared);
|
||||
let (ready, started) = oneshot::channel();
|
||||
let pause = (method == NEXT_METHOD)
|
||||
.then(|| shared.pause_next(parent_id))
|
||||
.flatten();
|
||||
let callback_method = method.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
// 先把任务归属登记到父调用,再允许执行宿主操作
|
||||
if started.await.is_err() {
|
||||
return;
|
||||
}
|
||||
let result = handler.call(context, method, params, frame.payload).await;
|
||||
if pause.is_some()
|
||||
&& let Err(error) = response.resume_next(parent_id).await
|
||||
{
|
||||
response.cancel(parent_id, error);
|
||||
return;
|
||||
}
|
||||
drop(pause);
|
||||
let reply = match result {
|
||||
Ok(reply) => Frame {
|
||||
message: Message::Result {
|
||||
@@ -166,9 +163,14 @@ async fn read_loop<R: AsyncRead + Unpin>(
|
||||
),
|
||||
})),
|
||||
}
|
||||
drop(permit);
|
||||
});
|
||||
if shared.track_callback(parent_id, task.abort_handle()) {
|
||||
if shared.track_callback(
|
||||
parent_id,
|
||||
id,
|
||||
&callback_method,
|
||||
task.abort_handle(),
|
||||
permit,
|
||||
) {
|
||||
let _ = ready.send(());
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -46,7 +46,7 @@ impl Default for RpcLimits {
|
||||
maximum_executing_calls: 64,
|
||||
maximum_queued_calls: 64,
|
||||
maximum_queue_timeout: Duration::from_secs(30),
|
||||
maximum_callbacks: 64,
|
||||
maximum_callbacks: 320,
|
||||
handshake_timeout: Duration::from_secs(5),
|
||||
maximum_stderr_bytes: 64 * 1024,
|
||||
}
|
||||
@@ -225,7 +225,6 @@ pub(crate) struct Shared {
|
||||
pub process: ProcessControl,
|
||||
pub stopped: watch::Sender<bool>,
|
||||
callback_slots: Arc<Semaphore>,
|
||||
next_slots: Arc<Semaphore>,
|
||||
execution: ExecutionCapacity,
|
||||
cancellations_changed: Notify,
|
||||
}
|
||||
@@ -245,14 +244,48 @@ struct PendingCall {
|
||||
context: CallContext,
|
||||
result: Option<oneshot::Sender<Result<RpcReply, RpcError>>>,
|
||||
stream: Option<StreamIngress>,
|
||||
callbacks: Vec<tokio::task::AbortHandle>,
|
||||
scheduling: Option<Scheduling>,
|
||||
callbacks: BTreeMap<u64, CallbackTask>,
|
||||
}
|
||||
|
||||
// 调度状态只属于父任务;回调完成与恢复授权分别推进
|
||||
enum Scheduling {
|
||||
Running {
|
||||
epoch: u64,
|
||||
},
|
||||
Suspended {
|
||||
epoch: u64,
|
||||
pause: Option<CallDeadlinePause>,
|
||||
},
|
||||
Resuming {
|
||||
epoch: u64,
|
||||
pause: Option<CallDeadlinePause>,
|
||||
task: tokio::task::AbortHandle,
|
||||
},
|
||||
}
|
||||
|
||||
struct CallbackTask {
|
||||
task: tokio::task::AbortHandle,
|
||||
pauses_budget: bool,
|
||||
_permit: OwnedSemaphorePermit,
|
||||
}
|
||||
|
||||
impl Drop for CallbackTask {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingCall {
|
||||
fn finish(mut self, result: Result<RpcReply, RpcError>) {
|
||||
for callback in self.callbacks.drain(..) {
|
||||
callback.abort();
|
||||
fn stop_resumption(&mut self) {
|
||||
if let Some(Scheduling::Resuming { task, .. }) = self.scheduling.take() {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(mut self, result: Result<RpcReply, RpcError>) {
|
||||
self.callbacks.clear();
|
||||
self.stop_resumption();
|
||||
let stream = self.stream.take();
|
||||
let sender = self.result.take();
|
||||
// 先同步关闭调用资源,Core 才能在收到结果时看到全部发送副作用
|
||||
@@ -318,12 +351,16 @@ impl Shared {
|
||||
{
|
||||
log_timeout(&call.context, "plugin", "initial_reply", "remote_timeout");
|
||||
}
|
||||
if result.is_ok() && !matches!(call.scheduling, Some(Scheduling::Running { .. })) {
|
||||
return Err(RpcError::Protocol(None));
|
||||
}
|
||||
if call.stream.is_some() && result.is_ok() {
|
||||
if call.context.resource_stream {
|
||||
call.deadline.clear();
|
||||
}
|
||||
let initial = call.result.take().ok_or(RpcError::Protocol(None))?;
|
||||
call.execution.take();
|
||||
call.scheduling.take();
|
||||
call.handler.streaming(&call.context);
|
||||
let _ = initial.send(result);
|
||||
} else {
|
||||
@@ -404,43 +441,6 @@ impl Shared {
|
||||
.map(|call| call.context.clone())
|
||||
}
|
||||
|
||||
pub fn pause_next(&self, id: u64) -> Option<CallDeadlinePause> {
|
||||
let mut state = self.state();
|
||||
let call = state
|
||||
.calls
|
||||
.get_mut(&id)
|
||||
.filter(|call| call.cancellation.is_none())?;
|
||||
let pause = call.deadline.pause()?;
|
||||
call.execution.take();
|
||||
Some(pause)
|
||||
}
|
||||
|
||||
pub async fn resume_next(&self, id: u64) -> Result<(), RpcError> {
|
||||
{
|
||||
let state = self.state();
|
||||
let call = state
|
||||
.calls
|
||||
.get(&id)
|
||||
.filter(|call| call.cancellation.is_none())
|
||||
.ok_or(RpcError::Cancelled)?;
|
||||
// 已交付响应头的流只占在途资源,不重新占用首结果执行名额
|
||||
if call.result.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let permit = self.execution.resume().await?;
|
||||
let mut state = self.state();
|
||||
let call = state
|
||||
.calls
|
||||
.get_mut(&id)
|
||||
.filter(|call| call.cancellation.is_none())
|
||||
.ok_or(RpcError::Cancelled)?;
|
||||
if call.result.is_some() {
|
||||
call.execution = Some(permit);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_cancelling(&self, id: u64) -> bool {
|
||||
self.state()
|
||||
.calls
|
||||
@@ -489,9 +489,8 @@ impl Shared {
|
||||
}
|
||||
call.cancellation = Some(error.clone());
|
||||
call.handler.finish(&call.context);
|
||||
for callback in call.callbacks.drain(..) {
|
||||
callback.abort();
|
||||
}
|
||||
call.callbacks.clear();
|
||||
call.stop_resumption();
|
||||
if let Some(result) = call.result.take() {
|
||||
let _ = result.send(Err(error.clone()));
|
||||
}
|
||||
@@ -524,21 +523,134 @@ impl Shared {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn track_callback(&self, parent_id: u64, callback: tokio::task::AbortHandle) -> bool {
|
||||
if let Some(call) = self
|
||||
.state()
|
||||
pub fn track_callback(
|
||||
&self,
|
||||
parent_id: u64,
|
||||
id: u64,
|
||||
method: &str,
|
||||
task: tokio::task::AbortHandle,
|
||||
permit: OwnedSemaphorePermit,
|
||||
) -> bool {
|
||||
let mut state = self.state();
|
||||
let Some(call) = state
|
||||
.calls
|
||||
.get_mut(&parent_id)
|
||||
.filter(|call| call.cancellation.is_none())
|
||||
else {
|
||||
task.abort();
|
||||
return false;
|
||||
};
|
||||
let pauses_budget = method == gateway_plugin_sdk::call::middleware::NEXT_METHOD;
|
||||
if !pauses_budget
|
||||
&& let Some(Scheduling::Suspended { pause, .. } | Scheduling::Resuming { pause, .. }) =
|
||||
call.scheduling.as_mut()
|
||||
{
|
||||
// 已完成回调的句柄不继续占用请求预算
|
||||
call.callbacks.retain(|handle| !handle.is_finished());
|
||||
call.callbacks.push(callback);
|
||||
true
|
||||
} else {
|
||||
callback.abort();
|
||||
false
|
||||
pause.take();
|
||||
}
|
||||
call.callbacks.insert(
|
||||
id,
|
||||
CallbackTask {
|
||||
task,
|
||||
pauses_budget,
|
||||
_permit: permit,
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn consume_callback(&self, parent_id: u64, id: u64) -> Result<(), RpcError> {
|
||||
if id == 0 || !id.is_multiple_of(2) || parent_id == 0 || parent_id.is_multiple_of(2) {
|
||||
return Err(RpcError::Protocol(None));
|
||||
}
|
||||
// 取消确认可以与消费确认交错;确认只能撤销资源,不能重新创建调用
|
||||
if let Some(call) = self.state().calls.get_mut(&parent_id) {
|
||||
call.callbacks.remove(&id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn suspend(&self, id: u64, epoch: u64) -> Result<(), RpcError> {
|
||||
if id == 0 || id.is_multiple_of(2) || epoch == 0 {
|
||||
return Err(RpcError::Protocol(None));
|
||||
}
|
||||
let mut state = self.state();
|
||||
let Some(call) = state.calls.get_mut(&id) else {
|
||||
return Ok(());
|
||||
};
|
||||
if call.cancellation.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
match call.scheduling.as_ref() {
|
||||
Some(Scheduling::Running { epoch: previous })
|
||||
if previous.checked_add(1) == Some(epoch) => {}
|
||||
_ => return Err(RpcError::Protocol(None)),
|
||||
}
|
||||
let pauses = !call.callbacks.is_empty()
|
||||
&& call
|
||||
.callbacks
|
||||
.values()
|
||||
.all(|callback| callback.pauses_budget);
|
||||
let pause = pauses.then(|| call.deadline.pause()).flatten();
|
||||
call.execution.take();
|
||||
call.scheduling = Some(Scheduling::Suspended { epoch, pause });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn request_resume(self: &Arc<Self>, id: u64, epoch: u64) -> Result<(), RpcError> {
|
||||
if id == 0 || id.is_multiple_of(2) || epoch == 0 {
|
||||
return Err(RpcError::Protocol(None));
|
||||
}
|
||||
let mut state = self.state();
|
||||
let Some(call) = state.calls.get_mut(&id) else {
|
||||
return Ok(());
|
||||
};
|
||||
if call.cancellation.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
if !matches!(call.scheduling, Some(Scheduling::Suspended { epoch: current, .. }) if current == epoch)
|
||||
{
|
||||
return Err(RpcError::Protocol(None));
|
||||
}
|
||||
let Some(Scheduling::Suspended { pause, .. }) = call.scheduling.take() else {
|
||||
return Err(RpcError::Protocol(None));
|
||||
};
|
||||
let shared = Arc::clone(self);
|
||||
let (ready, started) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
if started.await.is_err() {
|
||||
return;
|
||||
}
|
||||
match shared.execution.resume().await {
|
||||
Ok(permit) => shared.grant_resume(id, epoch, permit),
|
||||
Err(error) => shared.cancel(id, error),
|
||||
}
|
||||
});
|
||||
call.scheduling = Some(Scheduling::Resuming {
|
||||
epoch,
|
||||
pause,
|
||||
task: task.abort_handle(),
|
||||
});
|
||||
let _ = ready.send(());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn grant_resume(&self, id: u64, epoch: u64, permit: OwnedSemaphorePermit) {
|
||||
let mut state = self.state();
|
||||
let Some(call) = state
|
||||
.calls
|
||||
.get_mut(&id)
|
||||
.filter(|call| call.cancellation.is_none())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !matches!(call.scheduling, Some(Scheduling::Resuming { epoch: current, .. }) if current == epoch)
|
||||
{
|
||||
return;
|
||||
}
|
||||
call.execution = Some(permit);
|
||||
call.scheduling = Some(Scheduling::Running { epoch });
|
||||
drop(state);
|
||||
self.send_control(Frame::control(Message::Resumed { id, epoch }));
|
||||
}
|
||||
|
||||
pub fn send_control(&self, frame: Frame) {
|
||||
@@ -555,23 +667,18 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn try_callback_slot(&self, method: &str) -> Option<OwnedSemaphorePermit> {
|
||||
let slots = if method == gateway_plugin_sdk::call::middleware::NEXT_METHOD {
|
||||
&self.next_slots
|
||||
} else {
|
||||
&self.callback_slots
|
||||
};
|
||||
slots.clone().try_acquire_owned().ok()
|
||||
pub(super) fn try_callback_slot(&self) -> Option<OwnedSemaphorePermit> {
|
||||
self.callback_slots.clone().try_acquire_owned().ok()
|
||||
}
|
||||
|
||||
async fn wait_for_callbacks(&self, limits: RpcLimits) {
|
||||
for (slots, maximum) in [
|
||||
(&self.callback_slots, limits.maximum_callbacks),
|
||||
(&self.next_slots, limits.maximum_calls),
|
||||
] {
|
||||
if let Ok(permits) = slots.clone().acquire_many_owned(maximum as u32).await {
|
||||
drop(permits);
|
||||
}
|
||||
if let Ok(permits) = self
|
||||
.callback_slots
|
||||
.clone()
|
||||
.acquire_many_owned(limits.maximum_callbacks as u32)
|
||||
.await
|
||||
{
|
||||
drop(permits);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -653,7 +760,7 @@ impl RpcSession {
|
||||
|| limits.maximum_queued_calls > 256
|
||||
|| limits.maximum_queue_timeout.is_zero()
|
||||
|| limits.maximum_callbacks == 0
|
||||
|| limits.maximum_callbacks > 256
|
||||
|| limits.maximum_callbacks > 512
|
||||
|| limits.maximum_buffered_body_bytes < 1024
|
||||
|| limits.maximum_buffered_body_bytes > 16 * 1024 * 1024
|
||||
|| limits.handshake_timeout.is_zero()
|
||||
@@ -771,7 +878,6 @@ impl RpcSession {
|
||||
process,
|
||||
stopped,
|
||||
callback_slots,
|
||||
next_slots: Arc::new(Semaphore::new(limits.maximum_calls)),
|
||||
execution: ExecutionCapacity::new(
|
||||
limits.maximum_executing_calls,
|
||||
limits.maximum_queued_calls,
|
||||
@@ -902,7 +1008,8 @@ impl RpcSession {
|
||||
context,
|
||||
result: Some(result),
|
||||
stream,
|
||||
callbacks: Vec::new(),
|
||||
scheduling: Some(Scheduling::Running { epoch: 0 }),
|
||||
callbacks: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
Ok(StartedCall {
|
||||
|
||||
@@ -273,3 +273,90 @@ async fn startup_failure_of_an_unrelated_plugin_preserves_native_authentication_
|
||||
}
|
||||
close(environment, runtime, core).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn callback_verification_reenters_the_authentication_instance_without_model_binding() {
|
||||
use gateway_admin::{
|
||||
model::plugins::management::PluginManagementRequest,
|
||||
ports::plugin_management::PluginManagement,
|
||||
};
|
||||
use gateway_plugin_runtime::RpcLimits;
|
||||
let Some(environment) = Environment::create().await else {
|
||||
return;
|
||||
};
|
||||
environment
|
||||
.client_key("key_frontend", "sk-native-fixture")
|
||||
.await;
|
||||
let account = environment.account(None).await;
|
||||
let marker = environment
|
||||
.directory
|
||||
.path()
|
||||
.join("authenticated-facts.json");
|
||||
let facts = json!([
|
||||
{"method":"host.data.access.check", "query":{"client_key_id":"key_frontend", "account_ids":[account.as_str(), "acct_missing"]}},
|
||||
{"method":"host.models.list", "params":{"client_key_id":"key_frontend", "protocol":"codex", "client_version":"0.154.0"}}
|
||||
]);
|
||||
let mut config = configuration(
|
||||
json!({"outcome":"authenticated","principal":"external-user"}),
|
||||
true,
|
||||
"External controlled-fixture",
|
||||
);
|
||||
config["sdk_session"] = json!(true);
|
||||
config["frontend_authentication_marker"] = json!(marker);
|
||||
config["auth_queries"] = facts.clone();
|
||||
config["management_registration"] = json!({"routes":[{"method":"GET","path":"facts","request_content_types":[],"response_content_types":["application/json"]}]});
|
||||
config["data_queries"] = json!([
|
||||
{"method":"host.client.verify", "query":{"authorization":"External controlled-fixture"}},
|
||||
facts[0].clone(), facts[1].clone()
|
||||
]);
|
||||
environment.install_plugin(config).await;
|
||||
let (runtime, core) = environment
|
||||
.runtime_with_limits(RpcLimits {
|
||||
maximum_executing_calls: 1,
|
||||
maximum_queue_timeout: std::time::Duration::from_millis(500),
|
||||
..RpcLimits::default()
|
||||
})
|
||||
.await;
|
||||
let snapshot = core.snapshots().acquire().unwrap();
|
||||
let generation = snapshot.extensions().unwrap();
|
||||
let view = runtime.views(generation).await.unwrap().remove(0);
|
||||
let response = runtime
|
||||
.handle(
|
||||
generation,
|
||||
&view.target,
|
||||
PluginManagementRequest {
|
||||
request_context: None,
|
||||
headers: Vec::new(),
|
||||
method: "GET".into(),
|
||||
path: "facts".into(),
|
||||
query: String::new(),
|
||||
content_type: None,
|
||||
body: Vec::new(),
|
||||
request_id: "verify-composition".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let results: Value = serde_json::from_slice(&response.body).unwrap();
|
||||
assert_eq!(results[0], json!({"client_key_id":"key_frontend"}));
|
||||
let authenticated_facts: Value =
|
||||
serde_json::from_slice(&std::fs::read(marker).unwrap()).unwrap();
|
||||
assert_eq!(authenticated_facts[0], results[1]);
|
||||
assert_eq!(
|
||||
authenticated_facts[0]["accounts"],
|
||||
json!([
|
||||
{"account_id":account.as_str(),"allowed":true}, {"account_id":"acct_missing","allowed":false}
|
||||
])
|
||||
);
|
||||
assert_eq!(authenticated_facts[1], results[2]);
|
||||
// 此替身只登记执行能力,没有原生目录;查询应成功返回空目录而不是执行绑定错误
|
||||
assert_eq!(results[2], json!({"models":[]}));
|
||||
assert!(
|
||||
environment
|
||||
.bound_model_requests("key_frontend")
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
drop(snapshot);
|
||||
close(environment, runtime, core).await;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ use gateway_plugin_sdk::{Capability, Contributions, Stage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Facts(AtomicUsize, AtomicUsize, Option<String>);
|
||||
struct Facts {
|
||||
reads: AtomicUsize,
|
||||
refreshes: AtomicUsize,
|
||||
email: Option<String>,
|
||||
runtime_account: Option<AccountRecord>,
|
||||
}
|
||||
|
||||
fn account() -> AccountRecord {
|
||||
let at = "2026-01-01T00:00:00Z".parse().unwrap();
|
||||
@@ -71,7 +76,7 @@ impl PluginAccountAccess for Facts {
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
) -> Result<ProviderQuota, AdminError> {
|
||||
self.1.fetch_add(1, Ordering::SeqCst);
|
||||
self.refreshes.fetch_add(1, Ordering::SeqCst);
|
||||
if account.as_str() == "acct_missing" {
|
||||
return Err(AdminError::not_found("missing account"));
|
||||
}
|
||||
@@ -88,19 +93,26 @@ impl PluginAccountAccess for Facts {
|
||||
}
|
||||
|
||||
async fn list(&self, query: PluginAccountListQuery) -> Result<PluginAccountPage, AdminError> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
self.reads.fetch_add(1, Ordering::SeqCst);
|
||||
assert_eq!(query.limit.get(), 1);
|
||||
assert_eq!(query.provider_kind.unwrap().as_str(), "openai");
|
||||
assert_eq!(query.cursor.unwrap().as_str(), "acct_before");
|
||||
let mut account = account();
|
||||
account.email.clone_from(&self.2);
|
||||
account.email.clone_from(&self.email);
|
||||
Ok(PluginAccountPage {
|
||||
accounts: vec![account],
|
||||
next_cursor: Some(ProviderAccountId::new("acct_facts").unwrap()),
|
||||
})
|
||||
}
|
||||
async fn get_runtime(&self, _: &ProviderAccountId) -> Result<AccountRecord, AdminError> {
|
||||
panic!("事实回调只使用对应的窄查询")
|
||||
async fn get_runtime(&self, id: &ProviderAccountId) -> Result<AccountRecord, AdminError> {
|
||||
let account = self
|
||||
.runtime_account
|
||||
.as_ref()
|
||||
.expect("事实回调只使用对应的窄查询");
|
||||
if account.id != id.as_str() {
|
||||
return Err(AdminError::not_found("fixture account is unavailable"));
|
||||
}
|
||||
Ok(account.clone())
|
||||
}
|
||||
async fn get_credential(
|
||||
&self,
|
||||
@@ -116,10 +128,14 @@ impl PluginAccountAccess for Facts {
|
||||
panic!("不允许修改账号")
|
||||
}
|
||||
async fn get_quota(&self, account: &ProviderAccountId) -> Result<ProviderQuota, AdminError> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
self.reads.fetch_add(1, Ordering::SeqCst);
|
||||
assert_eq!(account.as_str(), "acct_facts");
|
||||
Ok(ProviderQuota {
|
||||
credits: None,
|
||||
credits: Some(ProviderQuotaCredits {
|
||||
has_credits: true,
|
||||
unlimited: false,
|
||||
balance: Some("123456789.123456789".into()),
|
||||
}),
|
||||
plan_type: Some("private plan".into()),
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
@@ -129,17 +145,24 @@ impl PluginAccountAccess for Facts {
|
||||
label: "private label".into(),
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
role: None,
|
||||
role: Some(ProviderQuotaWindowRole::Primary),
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::Unavailable,
|
||||
window_seconds: Some(604800),
|
||||
used_percent: None,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
limit_reached: true,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
limit_reached: true,
|
||||
provider_data: Some(ProviderDocument::new(
|
||||
gateway_core::account::OpaqueProviderData::new(
|
||||
json!({"private":"provider-internal-fixture"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
),
|
||||
)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -157,11 +180,10 @@ async fn management_facts_validate_queries_without_permission_declarations() {
|
||||
Default::default(),
|
||||
)
|
||||
.await;
|
||||
let facts = Arc::new(Facts(
|
||||
AtomicUsize::new(0),
|
||||
AtomicUsize::new(0),
|
||||
email.map(str::to_owned),
|
||||
));
|
||||
let facts = Arc::new(Facts {
|
||||
email: email.map(str::to_owned),
|
||||
..Default::default()
|
||||
});
|
||||
let port: Arc<dyn PluginAccountAccess> = facts.clone();
|
||||
runtime.bind_account_ports(&port).unwrap();
|
||||
{
|
||||
@@ -172,6 +194,7 @@ async fn management_facts_validate_queries_without_permission_declarations() {
|
||||
"data_queries":[
|
||||
{"method":"host.data.accounts.list","query":{"provider_id":"openai","cursor":"acct_before","limit":1}},
|
||||
{"method":"host.data.quota.get","query":{"account_id":"acct_facts"}},
|
||||
{"method":"host.data.quota.details","query":{"account_id":"acct_facts"}},
|
||||
{"method":"host.data.accounts.list","query":{"limit":0}},
|
||||
{"method":"host.data.accounts.list","query":{"limit":65535}},
|
||||
{"method":"host.data.quota.get","query":{"account_id":"acct_facts","sql":"SELECT secret"}}
|
||||
@@ -209,7 +232,8 @@ async fn management_facts_validate_queries_without_permission_declarations() {
|
||||
.unwrap();
|
||||
let result: Value = serde_json::from_slice(&response.body).unwrap();
|
||||
|
||||
assert_eq!(facts.0.load(Ordering::SeqCst), 2, "{result}");
|
||||
assert_eq!(facts.reads.load(Ordering::SeqCst), 3, "{result}");
|
||||
assert_eq!(facts.refreshes.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(
|
||||
result[0],
|
||||
json!({"schema_version":1,"accounts":[{
|
||||
@@ -227,7 +251,19 @@ async fn management_facts_validate_queries_without_permission_declarations() {
|
||||
json!({"schema_version":1,"account_id":"acct_facts","observed_at_ms":null,
|
||||
"windows":[{"key":"weekly","window_seconds":604800,"used_percent":null,"reset_at_ms":null}]})
|
||||
);
|
||||
for invalid in &result.as_array().unwrap()[2..] {
|
||||
let details: gateway_plugin_sdk::call::data::QuotaDetails =
|
||||
serde_json::from_value(result[2].clone()).unwrap();
|
||||
assert_eq!(details.observed_at_ms, None);
|
||||
assert_eq!(details.windows[0].used_percent, None);
|
||||
assert_eq!(details.windows[0].role.as_deref(), Some("primary"));
|
||||
assert_eq!(details.windows[0].label, "private label");
|
||||
assert!(details.limit_reached && details.windows[0].limit_reached);
|
||||
assert_eq!(
|
||||
details.credits.unwrap().balance.as_deref(),
|
||||
Some("123456789.123456789")
|
||||
);
|
||||
assert!(!result[2].to_string().contains("provider-internal-fixture"));
|
||||
for invalid in &result.as_array().unwrap()[3..] {
|
||||
assert_eq!(invalid["error"], "invalid_input");
|
||||
}
|
||||
|
||||
@@ -301,7 +337,7 @@ async fn quota_refresh_is_available_without_permission_declarations() {
|
||||
.unwrap();
|
||||
let result: Value = serde_json::from_slice(&response.body).unwrap();
|
||||
|
||||
assert_eq!(facts.1.load(Ordering::SeqCst), 4);
|
||||
assert_eq!(facts.refreshes.load(Ordering::SeqCst), 4);
|
||||
assert_eq!(result[0]["observed_at_ms"], Value::Null);
|
||||
assert_eq!(
|
||||
result[1],
|
||||
@@ -323,3 +359,149 @@ async fn quota_refresh_is_available_without_permission_declarations() {
|
||||
super::wait_until_empty(cache.path()).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn management_queries(accounts: Arc<dyn PluginAccountAccess>, queries: Value) -> Value {
|
||||
let (cache, store, runtime) =
|
||||
super::setup_with_contributions(Contributions::from([crate::support::contribution(
|
||||
Capability::Management,
|
||||
vec![Stage::Management],
|
||||
vec![],
|
||||
vec![],
|
||||
)]))
|
||||
.await;
|
||||
runtime.bind_account_ports(&accounts).unwrap();
|
||||
store.snapshot.lock().unwrap().instances[0].configuration = json!({
|
||||
"management_registration":{"routes":[{"method":"GET","path":"facts","request_content_types":[],"response_content_types":["application/json"]}]},
|
||||
"data_queries":queries,
|
||||
"sdk_session":true,
|
||||
});
|
||||
let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let view = runtime.views(&generation).await.unwrap().remove(0);
|
||||
let response = runtime
|
||||
.handle(
|
||||
&generation,
|
||||
&view.target,
|
||||
PluginManagementRequest {
|
||||
request_context: None,
|
||||
headers: vec![],
|
||||
method: "GET".into(),
|
||||
path: "facts".into(),
|
||||
query: String::new(),
|
||||
content_type: None,
|
||||
body: vec![],
|
||||
request_id: "account-http-fixture".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = serde_json::from_slice(&response.body).unwrap();
|
||||
drop(generation);
|
||||
runtime.shutdown().await;
|
||||
super::wait_until_empty(cache.path()).await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn request_head(stream: &mut tokio::net::TcpStream) -> String {
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
let mut bytes = Vec::new();
|
||||
while !bytes.ends_with(b"\r\n\r\n") {
|
||||
assert!(bytes.len() < 16 * 1024);
|
||||
bytes.push(stream.read_u8().await.unwrap());
|
||||
}
|
||||
String::from_utf8(bytes).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_http_uses_the_selected_proxy_and_rejects_unavailable_account_versions() {
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
for scenario in [
|
||||
"plain",
|
||||
"explicit-auth",
|
||||
"rotated",
|
||||
"disabled",
|
||||
"proxy-failed",
|
||||
"missing",
|
||||
] {
|
||||
let target = wiremock::MockServer::start().await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let proxy = gateway_core::account::OutboundProxy::parse(&format!(
|
||||
"http://{}",
|
||||
listener.local_addr().unwrap()
|
||||
))
|
||||
.unwrap();
|
||||
let successful = matches!(scenario, "plain" | "explicit-auth");
|
||||
let proxy_task = if successful {
|
||||
Some(tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let request = request_head(&mut socket).await;
|
||||
assert!(request.starts_with("GET http://127.0.0.1:"));
|
||||
socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 201 Created\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
request
|
||||
}))
|
||||
} else {
|
||||
drop(listener);
|
||||
None
|
||||
};
|
||||
let mut selected = account();
|
||||
selected.outbound_proxy = Some(proxy);
|
||||
selected.enabled = scenario != "disabled";
|
||||
if scenario == "rotated" {
|
||||
selected.credential_revision = Revision::new(2).unwrap();
|
||||
}
|
||||
let accounts: Arc<dyn PluginAccountAccess> = Arc::new(Facts {
|
||||
runtime_account: Some(selected),
|
||||
..Default::default()
|
||||
});
|
||||
let headers = if scenario == "explicit-auth" {
|
||||
vec![("authorization", b"Bearer plugin-fixture".to_vec())]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let results = management_queries(accounts, json!([{
|
||||
"method":gateway_plugin_sdk::call::host::ACCOUNT_HTTP,
|
||||
"params":{
|
||||
"account_id":if scenario == "missing" {"acct_missing"} else {"acct_facts"},
|
||||
"credential_revision":1,
|
||||
"request":{"method":"GET","url":format!("{}/facts", target.uri()),"headers":headers},
|
||||
},
|
||||
}])).await;
|
||||
if let Some(task) = proxy_task {
|
||||
assert_eq!(results[0]["status"], 201, "{scenario}: {results}");
|
||||
let request = tokio::time::timeout(std::time::Duration::from_secs(3), task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.to_ascii_lowercase();
|
||||
assert!(
|
||||
request.starts_with("get http://127.0.0.1:") && request.contains("/facts http/1.1"),
|
||||
"{request}"
|
||||
);
|
||||
assert_eq!(
|
||||
request.contains("authorization:"),
|
||||
scenario == "explicit-auth"
|
||||
);
|
||||
if scenario == "explicit-auth" {
|
||||
assert!(request.contains("authorization: bearer plugin-fixture\r\n"));
|
||||
}
|
||||
} else {
|
||||
let expected = match scenario {
|
||||
"rotated" => "conflict",
|
||||
"disabled" => "permission_denied",
|
||||
"missing" => "rejected",
|
||||
_ => "upstream",
|
||||
};
|
||||
assert_eq!(results[0]["error"], expected, "{scenario}: {results}");
|
||||
}
|
||||
assert!(
|
||||
target.received_requests().await.unwrap().is_empty(),
|
||||
"{scenario} must not connect directly"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ fn limits() -> RpcLimits {
|
||||
maximum_executing_calls: 1,
|
||||
maximum_queued_calls: 1,
|
||||
maximum_queue_timeout: Duration::from_millis(200),
|
||||
maximum_callbacks: 1,
|
||||
maximum_callbacks: 4,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ async fn next_resumption_does_not_need_a_vacant_initial_queue_slot() {
|
||||
}
|
||||
});
|
||||
started.recv().await.unwrap();
|
||||
// 等待 next 不挤占唯一的普通回调额度
|
||||
// 未消费的 next 与普通回调共享有界驻留额度
|
||||
let mut occupying = Box::pin(call(&session, "callback"));
|
||||
assert!(futures::poll!(&mut occupying).is_pending());
|
||||
started.recv().await.unwrap();
|
||||
@@ -326,3 +326,246 @@ async fn resumption_queue_wait_preserves_the_remaining_plugin_processing_budget(
|
||||
assert!(session.is_ready());
|
||||
session.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
async fn scheduled<C: CallbackHandler + 'static>(
|
||||
callbacks: Arc<C>,
|
||||
mut limits: RpcLimits,
|
||||
) -> (tempfile::TempDir, Arc<RpcSession>) {
|
||||
let (cache, prepared, mut handshake, processes) = prepared_session();
|
||||
handshake.configuration = json!({"sdk_session": true});
|
||||
limits.maximum_queue_timeout = Duration::from_secs(2);
|
||||
let session = RpcSession::start(prepared, handshake, limits, &processes, callbacks)
|
||||
.await
|
||||
.unwrap();
|
||||
(cache, Arc::new(session))
|
||||
}
|
||||
|
||||
struct CrossCallbacks {
|
||||
other: std::sync::Mutex<std::sync::Weak<RpcSession>>,
|
||||
ready: Arc<tokio::sync::Barrier>,
|
||||
}
|
||||
|
||||
impl CallbackHandler for CrossCallbacks {
|
||||
fn call(
|
||||
&self,
|
||||
_context: CallContext,
|
||||
_method: String,
|
||||
_params: serde_json::Value,
|
||||
_payload: Vec<u8>,
|
||||
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
|
||||
let other = self.other.lock().unwrap().upgrade().unwrap();
|
||||
let ready = self.ready.clone();
|
||||
Box::pin(async move {
|
||||
ready.wait().await;
|
||||
other
|
||||
.call(
|
||||
"echo",
|
||||
other.context(Stage::Request, Duration::from_secs(2)),
|
||||
json!("child"),
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| PluginFault::new(gateway_plugin_sdk::ErrorCode::Fault, "child failed"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn independent_sessions_release_execution_for_every_callback_method() {
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let left = Arc::new(CrossCallbacks {
|
||||
other: Default::default(),
|
||||
ready: barrier.clone(),
|
||||
});
|
||||
let right = Arc::new(CrossCallbacks {
|
||||
other: Default::default(),
|
||||
ready: barrier,
|
||||
});
|
||||
let (_left_cache, a) = scheduled(left.clone(), limits()).await;
|
||||
let (_right_cache, b) = scheduled(right.clone(), limits()).await;
|
||||
*left.other.lock().unwrap() = Arc::downgrade(&b);
|
||||
*right.other.lock().unwrap() = Arc::downgrade(&a);
|
||||
for method in [
|
||||
"host.service.call",
|
||||
"host.http.dispatch",
|
||||
"host.model.execute",
|
||||
"host.client.verify",
|
||||
NEXT_METHOD,
|
||||
"host.unclassified",
|
||||
] {
|
||||
let invoke = |session: Arc<RpcSession>| async move {
|
||||
session
|
||||
.call(
|
||||
"callback",
|
||||
session.context(Stage::Request, Duration::from_secs(2)),
|
||||
json!({"method":method}),
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
};
|
||||
let (a_result, b_result) = tokio::time::timeout(Duration::from_secs(3), async {
|
||||
tokio::join!(invoke(a.clone()), invoke(b.clone()))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(a_result.unwrap().result, json!("child"), "{method}");
|
||||
assert_eq!(b_result.unwrap().result, json!("child"), "{method}");
|
||||
}
|
||||
a.shutdown(Duration::from_secs(1)).await;
|
||||
b.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
struct PartialCallbacks {
|
||||
entered: mpsc::UnboundedSender<String>,
|
||||
fast: Arc<Semaphore>,
|
||||
slow: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl CallbackHandler for PartialCallbacks {
|
||||
fn call(
|
||||
&self,
|
||||
_context: CallContext,
|
||||
method: String,
|
||||
_params: serde_json::Value,
|
||||
_payload: Vec<u8>,
|
||||
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
|
||||
self.entered.send(method.clone()).unwrap();
|
||||
let gate = match method.as_str() {
|
||||
"fast" => Some(self.fast.clone()),
|
||||
"slow" => Some(self.slow.clone()),
|
||||
_ => None,
|
||||
};
|
||||
Box::pin(async move {
|
||||
if let Some(gate) = gate {
|
||||
gate.acquire().await.unwrap().forget();
|
||||
}
|
||||
Ok(RpcReply {
|
||||
result: json!(method),
|
||||
payload: Vec::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_partial_callback_result_requires_a_permit_and_can_wait_again() {
|
||||
let (entered, mut events) = mpsc::unbounded_channel();
|
||||
let fast = Arc::new(Semaphore::new(0));
|
||||
let slow = Arc::new(Semaphore::new(0));
|
||||
let (cache, session) = scheduled(
|
||||
Arc::new(PartialCallbacks {
|
||||
entered,
|
||||
fast: fast.clone(),
|
||||
slow: slow.clone(),
|
||||
}),
|
||||
limits(),
|
||||
)
|
||||
.await;
|
||||
let parent = tokio::spawn({
|
||||
let session = session.clone();
|
||||
async move { call(&session, "partial").await }
|
||||
});
|
||||
for _ in 0..2 {
|
||||
tokio::time::timeout(Duration::from_secs(2), events.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
let marker = cache.path().join("processing");
|
||||
let busy = tokio::spawn({
|
||||
let session = session.clone();
|
||||
let marker = marker.clone();
|
||||
async move {
|
||||
session
|
||||
.call(
|
||||
"busy",
|
||||
session.context(Stage::Request, Duration::from_secs(2)),
|
||||
json!({"marker":marker, "delay_ms":300}),
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !marker.exists() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
fast.add_permits(1);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), events.recv())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
busy.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
tokio::time::timeout(Duration::from_secs(2), events.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("processed")
|
||||
);
|
||||
assert!(!parent.is_finished());
|
||||
// 再次等待慢回调后,独立调用可取得同一个执行名额
|
||||
assert!(call(&session, "echo").await.is_ok());
|
||||
slow.add_permits(1);
|
||||
assert_eq!(parent.await.unwrap().unwrap().result, json!("complete"));
|
||||
session.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn polling_wakeups_and_cancelled_suspensions_leave_the_session_usable() {
|
||||
let (entered, mut events) = mpsc::unbounded_channel();
|
||||
let slow = Arc::new(Semaphore::new(0));
|
||||
let (_cache, session) = scheduled(
|
||||
Arc::new(PartialCallbacks {
|
||||
entered,
|
||||
fast: Arc::new(Semaphore::new(0)),
|
||||
slow,
|
||||
}),
|
||||
RpcLimits {
|
||||
maximum_calls: 1,
|
||||
..limits()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
call(&session, "yield").await.unwrap().result,
|
||||
json!("yielded")
|
||||
);
|
||||
let parent = tokio::spawn({
|
||||
let session = session.clone();
|
||||
async move {
|
||||
session
|
||||
.call(
|
||||
"callback",
|
||||
session.context(Stage::Request, Duration::from_secs(2)),
|
||||
json!({"method":"slow"}),
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
assert_eq!(events.recv().await.as_deref(), Some("slow"));
|
||||
assert!(matches!(
|
||||
call(&session, "echo").await,
|
||||
Err(RpcError::Overloaded)
|
||||
));
|
||||
parent.abort();
|
||||
assert!(matches!(parent.await, Err(error) if error.is_cancelled()));
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
loop {
|
||||
match call(&session, "echo").await {
|
||||
Ok(_) => break,
|
||||
Err(RpcError::Overloaded) => tokio::task::yield_now().await,
|
||||
Err(error) => panic!("{error}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(session.is_ready());
|
||||
session.shutdown(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ use gateway_plugin_sdk::call::{
|
||||
upstream_adapter::{UpstreamAdapterEvent, UpstreamAdapterRequest},
|
||||
};
|
||||
|
||||
mod scheduled;
|
||||
|
||||
type CallbackResult = Result<(Value, Vec<u8>), PluginFault>;
|
||||
struct PendingCallback {
|
||||
parent: u64,
|
||||
@@ -82,6 +84,7 @@ struct Peer {
|
||||
output: mpsc::Sender<Frame>,
|
||||
callbacks: Mutex<BTreeMap<u64, PendingCallback>>,
|
||||
next_callback: AtomicU64,
|
||||
resumptions: Mutex<BTreeMap<u64, oneshot::Sender<()>>>,
|
||||
streams: Mutex<BTreeMap<u64, Arc<Credits>>>,
|
||||
}
|
||||
|
||||
@@ -90,18 +93,21 @@ impl Peer {
|
||||
let queries = self.configuration["data_queries"].as_array()?;
|
||||
let mut results = Vec::new();
|
||||
for query in queries {
|
||||
let metadata_query = query["method"] == "host.keys.list";
|
||||
let metadata_query =
|
||||
query["method"] == "host.keys.list" || query.get("params").is_some();
|
||||
let result = self
|
||||
.callback_payload(
|
||||
id,
|
||||
query["method"].as_str().unwrap(),
|
||||
if metadata_query {
|
||||
query["query"].clone()
|
||||
query.get("params").unwrap_or(&query["query"]).clone()
|
||||
} else {
|
||||
json!({})
|
||||
},
|
||||
if metadata_query {
|
||||
vec![]
|
||||
query["body"]
|
||||
.as_str()
|
||||
.map_or_else(Vec::new, |body| body.as_bytes().to_vec())
|
||||
} else {
|
||||
serde_json::to_vec(&query["query"]).unwrap()
|
||||
},
|
||||
@@ -243,6 +249,36 @@ impl Peer {
|
||||
self.callback_payload(parent, method, params, vec![]).await
|
||||
}
|
||||
|
||||
async fn send_callback(
|
||||
&self,
|
||||
parent: u64,
|
||||
method: &str,
|
||||
params: Value,
|
||||
payload: Vec<u8>,
|
||||
) -> (u64, oneshot::Receiver<CallbackResult>) {
|
||||
let (response, received) = oneshot::channel();
|
||||
let mut callbacks = self.callbacks.lock().await;
|
||||
let id = self.next_callback.fetch_add(2, Ordering::Relaxed);
|
||||
callbacks.insert(
|
||||
id,
|
||||
PendingCallback {
|
||||
parent,
|
||||
response: Some(response),
|
||||
},
|
||||
);
|
||||
self.send(
|
||||
Message::Callback {
|
||||
id,
|
||||
parent_id: parent,
|
||||
method: method.into(),
|
||||
params,
|
||||
},
|
||||
payload,
|
||||
)
|
||||
.await;
|
||||
(id, received)
|
||||
}
|
||||
|
||||
async fn callback_payload(
|
||||
&self,
|
||||
parent: u64,
|
||||
@@ -250,29 +286,17 @@ impl Peer {
|
||||
params: Value,
|
||||
payload: Vec<u8>,
|
||||
) -> CallbackResult {
|
||||
let (response, received) = oneshot::channel();
|
||||
{
|
||||
let mut callbacks = self.callbacks.lock().await;
|
||||
let id = self.next_callback.fetch_add(2, Ordering::Relaxed);
|
||||
callbacks.insert(
|
||||
let (id, received) = self.send_callback(parent, method, params, payload).await;
|
||||
let result = received.await.unwrap();
|
||||
self.send(
|
||||
Message::CallbackConsumed {
|
||||
id,
|
||||
PendingCallback {
|
||||
parent,
|
||||
response: Some(response),
|
||||
},
|
||||
);
|
||||
self.send(
|
||||
Message::Callback {
|
||||
id,
|
||||
parent_id: parent,
|
||||
method: method.into(),
|
||||
params,
|
||||
},
|
||||
payload,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
received.await.unwrap()
|
||||
parent_id: parent,
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_nested_model_fixture(
|
||||
@@ -1349,31 +1373,43 @@ impl Peer {
|
||||
"slow" => tokio::time::sleep(Duration::from_millis(400)).await,
|
||||
"hang" | "hang_uncancellable" => return,
|
||||
"crash" => std::process::exit(7),
|
||||
"callback" | "callback_method" | "forged_callback" => {
|
||||
"callback" | "callback_method" => {
|
||||
let callback_method = if method == "callback_method" {
|
||||
params["method"].as_str().unwrap().to_owned()
|
||||
} else {
|
||||
"host.log".into()
|
||||
};
|
||||
// 分配与入队保持同一顺序,避免并发回调制造不合法 ID 序列
|
||||
let mut callbacks = self.callbacks.lock().await;
|
||||
let callback = self.next_callback.fetch_add(2, Ordering::Relaxed);
|
||||
callbacks.insert(
|
||||
callback,
|
||||
PendingCallback {
|
||||
parent: id,
|
||||
response: None,
|
||||
let (callback, received) = self
|
||||
.send_callback(id, &callback_method, params, payload)
|
||||
.await;
|
||||
self.send(Message::Suspend { id, epoch: 1 }, vec![]).await;
|
||||
let result = received.await.unwrap();
|
||||
let (resumed, granted) = oneshot::channel();
|
||||
self.resumptions.lock().await.insert(id, resumed);
|
||||
self.send(Message::Resume { id, epoch: 1 }, vec![]).await;
|
||||
granted.await.unwrap();
|
||||
self.send(
|
||||
Message::CallbackConsumed {
|
||||
id: callback,
|
||||
parent_id: id,
|
||||
},
|
||||
);
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok((result, payload)) => {
|
||||
self.send(Message::Result { id, result }, payload).await
|
||||
}
|
||||
Err(error) => self.send(Message::Error { id, error }, vec![]).await,
|
||||
}
|
||||
return;
|
||||
}
|
||||
"forged_callback" => {
|
||||
self.send(
|
||||
Message::Callback {
|
||||
id: callback,
|
||||
parent_id: if method == "forged_callback" {
|
||||
999999
|
||||
} else {
|
||||
id
|
||||
},
|
||||
method: callback_method,
|
||||
id: self.next_callback.fetch_add(2, Ordering::Relaxed),
|
||||
parent_id: 999999,
|
||||
method: "host.log".into(),
|
||||
params,
|
||||
},
|
||||
payload,
|
||||
@@ -1426,7 +1462,7 @@ impl Peer {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn main() {
|
||||
let mut input = tokio::io::stdin();
|
||||
let (output, mut frames) = mpsc::channel::<Frame>(64);
|
||||
@@ -1437,6 +1473,11 @@ async fn main() {
|
||||
}
|
||||
});
|
||||
let hello = read_frame(&mut input).await.unwrap();
|
||||
if matches!(&hello.message, Message::Hello { handshake } if handshake.configuration["sdk_session"] == true)
|
||||
{
|
||||
scheduled::run(hello, input).await;
|
||||
return;
|
||||
}
|
||||
let Message::Hello { mut handshake } = hello.message else {
|
||||
panic!("expected handshake")
|
||||
};
|
||||
@@ -1491,6 +1532,7 @@ async fn main() {
|
||||
output,
|
||||
callbacks: Mutex::new(BTreeMap::new()),
|
||||
next_callback: AtomicU64::new(2),
|
||||
resumptions: Mutex::new(BTreeMap::new()),
|
||||
streams: Mutex::new(BTreeMap::new()),
|
||||
});
|
||||
let exit_after_ready = peer.configuration["exit_after_ready_delays_ms"]
|
||||
@@ -1603,6 +1645,11 @@ async fn main() {
|
||||
),
|
||||
);
|
||||
}
|
||||
Message::Resumed { id, epoch: 1 } => {
|
||||
if let Some(resumed) = peer.resumptions.lock().await.remove(&id) {
|
||||
let _ = resumed.send(());
|
||||
}
|
||||
}
|
||||
Message::Cancel { id } => {
|
||||
if uncancellable.contains(&id) {
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! 使用公开 SDK 驱动真实子进程,覆盖受管任务的并发与恢复
|
||||
|
||||
use gateway_plugin_sdk::{
|
||||
ErrorCode, Frame, PluginFault,
|
||||
client::{
|
||||
CallFuture, CallReply, PluginCall, PluginHandler, PluginSession, SessionConfig, write_frame,
|
||||
},
|
||||
};
|
||||
use serde_json::json;
|
||||
use tokio::io::AsyncReadExt as _;
|
||||
|
||||
pub(super) async fn run(hello: Frame, input: tokio::io::Stdin) {
|
||||
let mut prefix = Vec::new();
|
||||
write_frame(&mut prefix, &hello).await.unwrap();
|
||||
let input = std::io::Cursor::new(prefix).chain(input);
|
||||
let session = PluginSession::accept(input, tokio::io::stdout(), SessionConfig::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let handler = Handler {
|
||||
configuration: session.handshake().configuration.clone(),
|
||||
contributes: session.handshake().contributes.clone(),
|
||||
};
|
||||
session.run(handler).await.unwrap();
|
||||
}
|
||||
|
||||
struct Handler {
|
||||
configuration: serde_json::Value,
|
||||
contributes: gateway_plugin_sdk::Contributions,
|
||||
}
|
||||
|
||||
impl Handler {
|
||||
async fn queries(&self, call: &PluginCall, key: &str) -> Vec<serde_json::Value> {
|
||||
let mut results = Vec::new();
|
||||
for query in self.configuration[key].as_array().unwrap() {
|
||||
let metadata = query.get("params").is_some() || query["method"] == "host.keys.list";
|
||||
let params = if metadata {
|
||||
query.get("params").unwrap_or(&query["query"]).clone()
|
||||
} else {
|
||||
json!({})
|
||||
};
|
||||
let payload = if metadata {
|
||||
query["body"]
|
||||
.as_str()
|
||||
.map_or_else(Vec::new, |body| body.as_bytes().to_vec())
|
||||
} else {
|
||||
serde_json::to_vec(&query["query"]).unwrap()
|
||||
};
|
||||
let result = call
|
||||
.host
|
||||
.call(query["method"].as_str().unwrap(), params, payload)
|
||||
.await;
|
||||
results.push(match result {
|
||||
Ok(reply) if metadata => {
|
||||
assert!(reply.payload.is_empty());
|
||||
reply.result
|
||||
}
|
||||
Ok(reply) => {
|
||||
assert_eq!(reply.result, json!({}));
|
||||
serde_json::from_slice(&reply.payload).unwrap()
|
||||
}
|
||||
Err(error) => json!({"error":error.into_plugin_fault().code}),
|
||||
});
|
||||
}
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHandler for Handler {
|
||||
fn call(&self, call: PluginCall) -> CallFuture<'_> {
|
||||
Box::pin(async move {
|
||||
match call.method.as_str() {
|
||||
"plugin.register" => Ok(CallReply::unary(
|
||||
serde_json::to_value(gateway_plugin_sdk::call::registration::Registration {
|
||||
contributes: self.contributes.clone(),
|
||||
})
|
||||
.unwrap(),
|
||||
Vec::new(),
|
||||
)),
|
||||
"management.register" => Ok(CallReply::unary(
|
||||
json!({}),
|
||||
serde_json::to_vec(&self.configuration["management_registration"]).unwrap(),
|
||||
)),
|
||||
"frontend_auth.identifier" => Ok(CallReply::unary(
|
||||
json!({"identifier":"fixture-auth"}),
|
||||
Vec::new(),
|
||||
)),
|
||||
"frontend_auth.authenticate" => {
|
||||
let request: gateway_plugin_sdk::call::frontend_authentication::FrontendAuthenticationRequest = serde_json::from_slice(&call.payload).unwrap();
|
||||
assert_eq!(
|
||||
request.authorization,
|
||||
self.configuration["expected_frontend_authorization"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
);
|
||||
let facts = if self.configuration.get("auth_queries").is_some() {
|
||||
self.queries(&call, "auth_queries").await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let Some(path) =
|
||||
self.configuration["frontend_authentication_marker"].as_str()
|
||||
{
|
||||
std::fs::write(path, serde_json::to_vec(&facts).unwrap()).unwrap();
|
||||
}
|
||||
Ok(CallReply::unary(
|
||||
json!({}),
|
||||
serde_json::to_vec(&self.configuration["frontend_authentication_result"])
|
||||
.unwrap(),
|
||||
))
|
||||
}
|
||||
"management.handle" => {
|
||||
let results = self.queries(&call, "data_queries").await;
|
||||
Ok(CallReply::unary(
|
||||
json!({"status":200,"content_type":"application/json"}),
|
||||
serde_json::to_vec(&results).unwrap(),
|
||||
))
|
||||
}
|
||||
"callback" => {
|
||||
let reply = call
|
||||
.host
|
||||
.call(
|
||||
call.params["method"].as_str().unwrap(),
|
||||
json!({}),
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| error.into_plugin_fault())?;
|
||||
Ok(CallReply::unary(reply.result, reply.payload))
|
||||
}
|
||||
"partial" => {
|
||||
let fast = Box::pin(call.host.call("fast", json!({}), Vec::new()));
|
||||
let slow = Box::pin(call.host.call("slow", json!({}), Vec::new()));
|
||||
let (first, remaining) = match futures::future::select(fast, slow).await {
|
||||
futures::future::Either::Left(pair)
|
||||
| futures::future::Either::Right(pair) => pair,
|
||||
};
|
||||
first.map_err(|error| error.into_plugin_fault())?;
|
||||
call.host
|
||||
.call("processed", json!({}), Vec::new())
|
||||
.await
|
||||
.map_err(|error| error.into_plugin_fault())?;
|
||||
remaining.await.map_err(|error| error.into_plugin_fault())?;
|
||||
Ok(CallReply::unary(json!("complete"), Vec::new()))
|
||||
}
|
||||
"busy" => {
|
||||
std::fs::write(call.params["marker"].as_str().unwrap(), "entered").unwrap();
|
||||
// 保持一次 poll 未返回,代表确实占用执行名额的工作
|
||||
std::thread::sleep(std::time::Duration::from_millis(
|
||||
call.params["delay_ms"].as_u64().unwrap(),
|
||||
));
|
||||
Ok(CallReply::unary(json!("busy complete"), Vec::new()))
|
||||
}
|
||||
"yield" => {
|
||||
for _ in 0..16 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
Ok(CallReply::unary(json!("yielded"), Vec::new()))
|
||||
}
|
||||
"drop_callback" => {
|
||||
let mut pending = Box::pin(call.host.call("slow", json!({}), Vec::new()));
|
||||
assert!(futures::poll!(&mut pending).is_pending());
|
||||
drop(pending);
|
||||
Ok(CallReply::unary(json!("dropped"), Vec::new()))
|
||||
}
|
||||
"echo" => Ok(CallReply::unary(call.params, call.payload)),
|
||||
_ => Err(PluginFault::new(
|
||||
ErrorCode::InvalidInput,
|
||||
"unknown fixture method",
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,13 @@ sequenceDiagram
|
||||
H->>P: 注册能力,核对清单
|
||||
P-->>H: 注册结果
|
||||
H->>P: Call(ID、上下文、载荷)
|
||||
opt 需要宿主资源
|
||||
opt 父处理等待宿主回调
|
||||
P->>H: 回调(关联父调用 ID)
|
||||
P->>H: Suspend(父处理 Pending)
|
||||
H-->>P: 回调结果
|
||||
P->>H: Resume
|
||||
H-->>P: Resumed(取得执行名额)
|
||||
P->>H: CallbackConsumed
|
||||
end
|
||||
P-->>H: 结果或错误;流以 End 结束
|
||||
```
|
||||
@@ -78,14 +82,17 @@ sequenceDiagram
|
||||
### 并发与期限
|
||||
|
||||
每个宿主插件会话默认允许 64 个执行中的 RPC 调用、64 个初次排队调用和 256 个总在途上下文。
|
||||
初次排队和等待 `next` 后的恢复排队各限 30 秒,不扣插件自身的处理预算。
|
||||
恢复等待占总在途名额,不占初次排队名额。
|
||||
等待 `next` 时释放执行名额并暂停处理预算,返回后重新取得名额,继续同一次调用;
|
||||
已返回响应头的流继续占用在途预算,不占首结果执行名额。具体组合行为见[洋葱中间件](docs/capabilities.md#洋葱中间件)
|
||||
初次排队和恢复排队各限 30 秒;恢复等待占总在途名额,不占初次排队名额
|
||||
|
||||
插件侧 `SessionConfig.maximum_calls` 默认 256,限制包含下游等待和响应流在内的在途调用;
|
||||
`maximum_callbacks` 默认 64,限制普通宿主回调。宿主普通回调的默认上限同样为 64,
|
||||
`next` 使用总在途上限对应的独立回调预算,不挤占普通回调容量
|
||||
SDK 只在父处理 future 返回 `Pending` 后报告挂起,Runtime 随即归还执行名额。
|
||||
收到唤醒后,SDK 请求恢复,取得授权才继续驱动父任务;并行回调只完成一个也遵守这一边界。
|
||||
等待普通回调及其恢复排队继续计入处理预算;只有父任务挂起且驻留回调全部为 `next` 时暂停预算。
|
||||
外部取消、请求期限和排队上限独立生效,调度不保证插件自行创建的线程、任务或锁能够推进
|
||||
|
||||
插件侧 `SessionConfig.maximum_calls` 默认 256,限制包含下游等待和响应流在内的在途调用。
|
||||
SDK 和宿主的 `maximum_callbacks` 默认 320、最大 512,统一限制全部宿主回调,包含已返回但尚未消费的结果。
|
||||
取消或丢弃回调 future 会释放该回调;宿主不保证已发出的业务操作可以撤销,不自动重试或补偿。
|
||||
已返回响应头的流继续占用总在途预算,不占首结果执行名额,由正文或连接的 owner 管理真实寿命
|
||||
|
||||
实际容量由宿主预算与 SDK 会话配置共同限制,较小的插件侧上限会提前拒绝新调用。
|
||||
达到容量或等待上限时,中间件返回过载错误;HTTP 响应为 503(`middleware_capacity_unavailable`),
|
||||
@@ -144,7 +151,7 @@ session.run(handler).await?;
|
||||
首个流式结果成功返回后,正文、会话及关联回调由资源取消与终态回收,不再沿用初始期限。
|
||||
普通调用继续使用原期限;资源模式仍保留并发容量、Credit、父调用关联及取消约束
|
||||
|
||||
协议 v6 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
|
||||
协议 v7 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
|
||||
元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块,
|
||||
不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付
|
||||
|
||||
@@ -165,6 +172,17 @@ SDK 帧读写器自动拆装这些字段,不拼接复制二进制正文。`Mes
|
||||
底层帧读写方法不是取消安全的;自行编排会话时,不能在 `select!` 的其他分支完成后
|
||||
重建半途中的帧读写。`PluginSession` 会在处理调用完成通知时保留同一个读取 future,直到完整帧到达或会话关闭
|
||||
|
||||
RPC v7 的调度控制消息没有载荷:`Suspend { id, epoch }` 归还执行名额,`Resume { id, epoch }`
|
||||
请求同一轮恢复,宿主 `Resumed { id, epoch }` 授权后才能继续首结果前的处理。`epoch` 从 1 递增,
|
||||
每次挂起与恢复严格配对。插件发出的控制消息和业务帧使用同一有界 FIFO,保证回调登记先于挂起
|
||||
|
||||
`CallbackConsumed { id, parent_id }` 表示回调结果已消费或 future 已丢弃,宿主据此释放驻留额度并取消尚未完成的回调。
|
||||
结果接收本身不恢复父任务、不释放驻留额度;父调用终结统一回收剩余回调。
|
||||
取消、恢复授权和结果可能交错,终结后的消息不得重新打开调用
|
||||
|
||||
宿主只接受 RPC v7,不提供旧协议兼容或降级。使用旧 SDK 的插件须更新 SDK、重新构建并由 CLI 生成安装包后安装,
|
||||
只修改清单中的版本号不能满足任务挂起、恢复和回调回收合同
|
||||
|
||||
插件须校验 `Hello` 的协议版本。一次 `Call` 的结果、错误和流帧按 ID 关联;实例、代次、阶段、账号或资源 ID 用于关联调用和管理资源生命周期
|
||||
|
||||
## 本地验证
|
||||
|
||||
@@ -81,11 +81,14 @@ Provider 固定为宿主内置的 OpenAI 与 xAI。宿主开放以下 11 类扩
|
||||
| SDK 方法 | 回调 | 查询与结果 |
|
||||
| --- | --- | --- |
|
||||
| `call.host.account_facts(query)` | `host.data.accounts.list` | `AccountFactsQuery`:可选 `provider_id`、`cursor`,必填 `limit`(1~200);按账号 ID 升序,`next_cursor=null` 表示本页已结束 |
|
||||
| `call.host.quota_details(query)` | `host.data.quota.details` | `QuotaFactsQuery { account_id }`:读取完整额度事实,包含窗口归属、触顶和点数 |
|
||||
| `call.host.check_access(query)` | `host.data.access.check` | `AccessQuery { client_key_id, account_ids, upstream_model }`:读取 Key 对指定账号及可选上游模型的冻结授权 |
|
||||
| `call.host.verify_client(request)` | `host.client.verify` | `VerifyClientRequest { authorization }`:复用入口认证,返回 `VerifiedClient { client_key_id }` |
|
||||
| `call.host.quota_facts(query)` | `host.data.quota.get` | `QuotaFactsQuery { account_id }`:读取 Provider 现有观测,不访问上游刷新 |
|
||||
| `call.host.key_facts(query)` | `host.data.keys.get` | `ClientKeyFactsQuery { client_key_id }`:读取当前 Key 的启用状态及显式分组 ID,不返回密钥 |
|
||||
|
||||
类型在 `call::data`。控制参数为 `{}`,查询和结果使用二进制 JSON;结果固定 `schema_version=1`。
|
||||
SDK 解析响应时忽略未知字段,包含账号分页、账号及额度窗口;已知字段按声明校验类型与必填性,查询拒绝未知字段。
|
||||
类型在 `call::data`。控制参数为 `{}`,查询和结果使用二进制 JSON;事实结果固定 `schema_version=1`,认证结果只包含 `client_key_id`。
|
||||
SDK 解析响应时忽略未知字段,包含账号分页、账号及额度窗口;已知字段按声明校验类型与必填性,账号、Key 与额度查询拒绝未知字段,认证与权限查询忽略未知字段。
|
||||
插件须通过 `engines.codex-proxy-rs` 限定支持所用接口和字段的宿主版本
|
||||
|
||||
`key_facts` 每次读取当前管理数据,返回 `client_key_id`、`enabled`、`group_ids`;不存在的 Key 沿用事实接口的 `rejected` 错误
|
||||
@@ -93,10 +96,22 @@ SDK 解析响应时忽略未知字段,包含账号分页、账号及额度窗
|
||||
`group_ids` 表示显式绑定,包含停用分组,不是最终可路由账号集合;空绑定也不代表单账号范围。通过 `account_facts` 关联账号时需完整遍历分页,结果包含停用账号。跨查询关联及同步策略由插件负责,这些调用不构成跨查询事务,管理员修改绑定后应重新检查。
|
||||
账号仅返回 `account_id`、`provider_id`、`name`、`email`、`group_ids`、`enabled` 和 `updated_at_ms`,不附带令牌或代理信息。
|
||||
`name` 和 `email` 来自宿主已保存的账号资料;没有邮箱时 `email=null`,读取不会请求上游个人信息。
|
||||
额度仅返回观测时间与窗口的 `key`、`window_seconds`、`used_percent`、`reset_at_ms`。
|
||||
`quota_facts` 仅返回观测时间与窗口的 `key`、`window_seconds`、`used_percent`、`reset_at_ms`。
|
||||
时间均为 UTC Unix 毫秒,比例为百分数;未知值保留 `null`,不能解释为 0。`observed_at_ms=null` 表示没有可用观测时间,
|
||||
不保证当前缓存新鲜,不提供历史快照或多个查询之间的原子一致性
|
||||
|
||||
`quota_details` 保留 Provider 已解释的 `limit_reached`、窗口 `group`、`label`、`limit_id`、`limit_name`、`role`
|
||||
及点数 `has_credits`、`unlimited`、`balance`;缺失值仍为 `null`,不返回上游原始文档,不发起刷新
|
||||
|
||||
`verify_client` 使用当前请求设置和原认证计划,包含外部 principal 到 Key 的映射;没有 HTTP 入口时使用当前发布配置。
|
||||
认证可以调用认证插件,继续扣减父预算并响应取消;验证成功不做推理准入、账号租约、使用记录或计费。
|
||||
授权原文只作本次认证输入,不进入返回值
|
||||
|
||||
`check_access` 与 `host.models.list`、显式 Key 模型执行共用父调用内的身份和冻结权限快照。
|
||||
账号或模型不在冻结范围内时返回 `allowed=false`,不存在或不可用的 Key 沿用 Core 的身份拒绝。
|
||||
它们不建立模型执行图,不受模型递归深度或当前实例已入链影响;健康、冷却、额度、租约与实时可用性由实际执行检查。
|
||||
`upstream_model` 使用上游模型名,省略时只判断账号范围;返回结果不承诺后续请求一定可以执行
|
||||
|
||||
```rust,ignore
|
||||
use gateway_plugin_sdk::call::data::{AccountFactsQuery, QuotaFactsQuery};
|
||||
let page = call.host.account_facts(AccountFactsQuery {
|
||||
@@ -217,8 +232,8 @@ SDK 不自动重试。超时或断连不能证明写入未提交;重试上限
|
||||
原有 8 MiB 编码上限、Runtime 缓冲预算及流控窗口限制
|
||||
|
||||
HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt、outbound、路由、调度、重试和上游适配器
|
||||
沿用该次模型请求冻结的正文预算。显式 Key 模型调用的预检与执行使用同一绑定快照,
|
||||
同一父调用内复用 Key 时不受后续配置发布影响;父调用结束后重新绑定才读取新配置,无需重新启用插件
|
||||
沿用该次模型请求冻结的正文预算。显式 Key 模型调用的预检与执行使用同一身份快照,
|
||||
同一父调用内复用 Key 时不受后续配置发布影响;父调用结束后重新解析身份才读取新配置,无需重新启用插件
|
||||
|
||||
`ModelExecuteRequest.client_key_id` 可显式选择执行 Key,不与触发插件的父请求 Key 求交。省略时继承现有模型父请求身份;没有模型父请求的调用必须提供 Key。所选 Key 的启用状态、模型规则、账号组、并发和预算继续由 Core 执行
|
||||
嵌套调用与显式 Key 调用继承入口快照中的客户端 IP 和 User-Agent;没有 HTTP 入口的后台调用保持来源未知
|
||||
@@ -238,12 +253,17 @@ HTTP 和 request 中间件中的模型回调沿用有效请求设置;attempt
|
||||
父调用取消或结束时宿主回收未关闭的流,SDK 不后台预读。取消一次 `read()` 等待后,再次读取会继续同一次操作,不丢弃已经返回的分块。
|
||||
账号上游使用同一正文对象,使用方式见[受管上游](upstream-adapters.md)
|
||||
|
||||
`HostClient::account_http(AccountHttpRequest { account_id, credential_revision, request }, body)` 使用指定账号的当前代理配置,
|
||||
经 `host.http.account` 返回同一 `HostHttpBody`。每次发送核对账号存在、启用和凭据版本,版本变化须由插件重新读取后决定下一步。
|
||||
请求 headers 与正文完全由插件提供;宿主不注入账号认证、不申请推理租约,代理不可用时直接失败,不回退直连。
|
||||
这项能力与上游适配器的账号推理 HTTP 分开,适用于插件自行构造的账号网络请求
|
||||
|
||||
请求和响应 `headers` 使用 `(String, Vec<u8>)`,保留重复头与非 UTF-8 值;响应另有 `http_version`。
|
||||
读取至 EOF 后通过 `body.trailers()` 取得完整尾部头,读取中只包含已收到的部分。
|
||||
`collect` 只返回正文,需要尾部头时使用 `read`;底层非流式回复携带 `trailers`,流式回复在 `stream_read` 中交付尾部头
|
||||
|
||||
`host.http.do/do_stream` 和流读取/关闭方法提供受管 HTTP。正文使用独立二进制载荷,Host 统一执行代理、超时和背压,不按插件身份限制目标地址段。DNS 由宿主解析,连接保留目标 Host 与 TLS 域名校验;重定向不会自动跟随。
|
||||
受管 HTTP(包括 `host.upstream.http.do[_stream]`)的请求正文按有效设置
|
||||
受管 HTTP(包括 `host.http.account` 和 `host.upstream.http.do[_stream]`)的请求正文按有效设置
|
||||
`responses_max_decompressed_body_bytes` 检查。HTTP/request 中间件使用有效请求设置;
|
||||
attempt、outbound、路由、调度、重试和上游适配器使用执行冻结预算,其余调用读取当前发布配置。
|
||||
超限返回带 HTTP 413 的 `invalid_input` 和 `not_sent`,不向目标发送请求。
|
||||
@@ -275,7 +295,7 @@ attempt、outbound、路由、调度、重试和上游适配器使用执行冻
|
||||
`next.run(input)` 消费自身,最多执行一次;结果按相反顺序经过外层。重试或独立子请求通过宿主执行服务发起
|
||||
|
||||
调用上下文的 `timeout_ms` 限制插件自身处理时间,入站与返回处理合计使用同一预算。
|
||||
等待 `next` 时暂停该预算并释放执行名额;恢复后继续同一次调用,普通宿主回调不会暂停计时。
|
||||
父处理任务真正挂起时释放执行名额,恢复取得授权后继续同一次调用。仅等待 `next` 时暂停处理预算;并行包含普通回调时继续计时,具体规则见[并发与期限](../README.md#并发与期限)。
|
||||
排队、在途容量与过载行为见 [SDK 并发与期限](../README.md#并发与期限)。
|
||||
请求总期限、下游操作超时和取消信号独立生效,客户端取消或插件退出会结束等待并回收资源
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
| 版本字段 | 当前值 | 对应合同 |
|
||||
| --- | --- | --- |
|
||||
| `manifestVersion` / `manifest_schema_versions` | `2` | 插件清单格式 |
|
||||
| `package.protocolVersion` / `protocol_versions` | `6` | 插件进程 RPC 封装,不是 OpenAI 或 xAI 的业务协议 |
|
||||
| `package.protocolVersion` / `protocol_versions` | `7` | 插件进程 RPC 封装,不是 OpenAI 或 xAI 的业务协议 |
|
||||
| `contributes.<capability>.version` / `capabilities[].versions` | 见[扩展项简写](#扩展项简写) | 指定扩展能力的行为合同 |
|
||||
| 宿主声明的 `schema_version` | `2` | 宿主兼容声明自身的格式 |
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
清单不接受 `permissions` 字段;插件按[完整信任](#完整信任)运行
|
||||
|
||||
RPC v6 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
|
||||
RPC v7 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
|
||||
|
||||
安装包清单校验与已安装元数据读取是两个边界:清单拒绝未知字段,并检查必需字段、类型和版本;
|
||||
数据库元数据的读取规则见[制品与发布](../../../../../docs/architecture.md#制品配置与发布),不改变清单或 RPC 合同。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! 基础事实查询与主动额度刷新
|
||||
//! 响应忽略未知字段,以兼容宿主新增事实;查询仍严格校验字段
|
||||
//! 入口认证、权限与账号额度事实,以及主动额度刷新合同
|
||||
//! 响应忽略未知字段,以兼容宿主新增事实
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -7,6 +7,44 @@ pub const ACCOUNTS_LIST: &str = "host.data.accounts.list";
|
||||
pub const QUOTA_REFRESH: &str = "host.quota_observations.refresh";
|
||||
pub const QUOTA_GET: &str = "host.data.quota.get";
|
||||
pub const KEYS_GET: &str = "host.data.keys.get";
|
||||
pub const CLIENT_VERIFY: &str = "host.client.verify";
|
||||
pub const ACCESS_CHECK: &str = "host.data.access.check";
|
||||
pub const QUOTA_DETAILS: &str = "host.data.quota.details";
|
||||
|
||||
/// 交给宿主入口认证计划的凭据;只通过二进制载荷传输,不提供内容型 Debug
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VerifyClientRequest {
|
||||
pub authorization: String,
|
||||
}
|
||||
|
||||
/// 宿主认证与 principal 映射后的执行身份,不包含 Key 明文
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct VerifiedClient {
|
||||
pub client_key_id: String,
|
||||
}
|
||||
|
||||
/// 候选账号的冻结权限查询;上游模型名不解析为公开模型别名
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccessQuery {
|
||||
pub client_key_id: String,
|
||||
pub account_ids: Vec<String>,
|
||||
pub upstream_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 授权结果不代表账号健康、额度或执行准入结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccessFacts {
|
||||
pub schema_version: u32,
|
||||
pub client_key_id: String,
|
||||
/// 与查询候选保持相同顺序
|
||||
pub accounts: Vec<AccountAccessFacts>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AccountAccessFacts {
|
||||
pub account_id: String,
|
||||
pub allowed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -72,3 +110,37 @@ pub struct QuotaWindowFacts {
|
||||
pub used_percent: Option<f64>,
|
||||
pub reset_at_ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// Provider 已解析的窗口与点数事实,不包含原始 Provider 文档或宿主预测
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct QuotaDetails {
|
||||
pub schema_version: u32,
|
||||
pub account_id: String,
|
||||
pub observed_at_ms: Option<i64>,
|
||||
pub limit_reached: bool,
|
||||
pub windows: Vec<QuotaWindowDetails>,
|
||||
pub credits: Option<QuotaCredits>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct QuotaWindowDetails {
|
||||
pub key: String,
|
||||
pub group: String,
|
||||
pub label: String,
|
||||
pub limit_id: Option<String>,
|
||||
pub limit_name: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub window_seconds: Option<u64>,
|
||||
/// 百分比而非 0~1 比率;未知值为 null
|
||||
pub used_percent: Option<f64>,
|
||||
pub reset_at_ms: Option<i64>,
|
||||
pub limit_reached: bool,
|
||||
}
|
||||
|
||||
/// 余额沿用上游的十进制文本,不转为浮点数或把未知值解释为零
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct QuotaCredits {
|
||||
pub has_credits: bool,
|
||||
pub unlimited: bool,
|
||||
pub balance: Option<String>,
|
||||
}
|
||||
|
||||
@@ -66,6 +66,16 @@ pub struct HttpRequest {
|
||||
pub headers: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
pub const ACCOUNT_HTTP: &str = "host.http.account";
|
||||
|
||||
/// 显式选择账号代理和凭据版本;认证头仍由插件提供,不取得推理租约
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct AccountHttpRequest {
|
||||
pub account_id: String,
|
||||
pub credential_revision: u64,
|
||||
pub request: HttpRequest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HttpResponse {
|
||||
|
||||
@@ -5,6 +5,28 @@ use crate::{PluginFault, call::data};
|
||||
use super::{HostClient, payload_call};
|
||||
|
||||
impl HostClient {
|
||||
/// 通过宿主入口认证计划校验凭据并取得执行身份,不执行模型或记录使用事实
|
||||
///
|
||||
/// # Errors
|
||||
/// 凭据无效、认证失败或父调用取消时返回错误
|
||||
pub async fn verify_client(
|
||||
&self,
|
||||
request: data::VerifyClientRequest,
|
||||
) -> Result<data::VerifiedClient, PluginFault> {
|
||||
payload_call(self, data::CLIENT_VERIFY, request).await
|
||||
}
|
||||
|
||||
/// 读取所选 Key 对候选账号及上游模型的有效权限,不判断健康、额度或租约
|
||||
///
|
||||
/// # Errors
|
||||
/// 身份不可用、查询无效或父调用取消时返回错误
|
||||
pub async fn check_access(
|
||||
&self,
|
||||
query: data::AccessQuery,
|
||||
) -> Result<data::AccessFacts, PluginFault> {
|
||||
payload_call(self, data::ACCESS_CHECK, query).await
|
||||
}
|
||||
|
||||
/// 读取 Key 当前启用状态与显式分组绑定,不返回密钥
|
||||
///
|
||||
/// # Errors
|
||||
@@ -52,4 +74,15 @@ impl HostClient {
|
||||
) -> Result<data::QuotaFacts, PluginFault> {
|
||||
payload_call(self, data::QUOTA_GET, query).await
|
||||
}
|
||||
|
||||
/// 读取已有额度窗口标签、触顶与点数事实,不触发刷新或补算
|
||||
///
|
||||
/// # Errors
|
||||
/// 账号不存在或宿主读取失败时返回错误
|
||||
pub async fn quota_details(
|
||||
&self,
|
||||
query: data::QuotaFactsQuery,
|
||||
) -> Result<data::QuotaDetails, PluginFault> {
|
||||
payload_call(self, data::QUOTA_DETAILS, query).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,20 @@ impl HostClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// 使用指定账号的当前代理发起请求,认证头由调用方显式提供
|
||||
/// 账号必须启用且凭据版本匹配;代理失败不回退直连
|
||||
///
|
||||
/// # Errors
|
||||
/// 账号不可用、版本冲突、期限或网络请求失败时返回错误
|
||||
pub async fn account_http(
|
||||
&self,
|
||||
request: wire::AccountHttpRequest,
|
||||
body: Vec<u8>,
|
||||
) -> Result<HostHttpResponse, PluginFault> {
|
||||
self.open_http(wire::ACCOUNT_HTTP, request, body, false)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 由宿主附加已选账号的认证与代理,返回统一的惰性正文
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::SessionError;
|
||||
|
||||
const MAXIMUM_STREAM_CHUNK_BYTES: usize = 16 * 1024 * 1024;
|
||||
const MAXIMUM_CONCURRENCY: usize = 256;
|
||||
const MAXIMUM_CALLBACKS: usize = 512;
|
||||
const MAXIMUM_BUFFERED_STREAM_CHUNKS: usize = 65_536;
|
||||
|
||||
/// 插件侧会话的内存、并发与期限边界
|
||||
@@ -14,6 +15,7 @@ pub struct SessionConfig {
|
||||
/// 单个业务流分块的预算,不限制普通调用正文的总长度
|
||||
pub maximum_stream_chunk_bytes: usize,
|
||||
pub maximum_calls: usize,
|
||||
/// 包含等待下游与尚未消费结果的全部宿主回调
|
||||
pub maximum_callbacks: usize,
|
||||
pub maximum_buffered_stream_chunks: usize,
|
||||
pub handshake_timeout: Duration,
|
||||
@@ -25,7 +27,7 @@ impl Default for SessionConfig {
|
||||
// 流分块还需匹配宿主授予的信用窗口
|
||||
maximum_stream_chunk_bytes: 1024 * 1024,
|
||||
maximum_calls: 256,
|
||||
maximum_callbacks: 64,
|
||||
maximum_callbacks: 320,
|
||||
maximum_buffered_stream_chunks: 1_024,
|
||||
handshake_timeout: Duration::from_secs(5),
|
||||
}
|
||||
@@ -39,7 +41,7 @@ impl SessionConfig {
|
||||
|| self.maximum_calls == 0
|
||||
|| self.maximum_calls > MAXIMUM_CONCURRENCY
|
||||
|| self.maximum_callbacks == 0
|
||||
|| self.maximum_callbacks > MAXIMUM_CONCURRENCY
|
||||
|| self.maximum_callbacks > MAXIMUM_CALLBACKS
|
||||
|| self.maximum_buffered_stream_chunks == 0
|
||||
|| self.maximum_buffered_stream_chunks > MAXIMUM_BUFFERED_STREAM_CHUNKS
|
||||
|| self.handshake_timeout.is_zero()
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
//! 宿主与插件共用的调用处理预算,受管续体等待期间暂停并保留剩余额度
|
||||
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::{sync::watch, time::Instant};
|
||||
use tokio::{sync::Notify, time::Instant};
|
||||
|
||||
/// 同一次调用的所有等待点共享此期限,避免回调恢复后仍使用旧截止时间
|
||||
#[derive(Clone)]
|
||||
pub struct CallDeadline {
|
||||
state: watch::Sender<State>,
|
||||
shared: Arc<Shared>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
changed: Notify,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -25,13 +33,22 @@ pub struct CallDeadlinePause {
|
||||
impl CallDeadline {
|
||||
#[must_use]
|
||||
pub fn new(deadline: Instant) -> Self {
|
||||
let (state, _) = watch::channel(State::Running(deadline));
|
||||
Self { state }
|
||||
Self {
|
||||
shared: Arc::new(Shared {
|
||||
state: Mutex::new(State::Running(deadline)),
|
||||
changed: Notify::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn remaining(&self) -> Option<Duration> {
|
||||
match *self.state.borrow() {
|
||||
match *self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
{
|
||||
State::Running(at) => Some(at.saturating_duration_since(Instant::now())),
|
||||
State::Paused(remaining) => Some(remaining),
|
||||
State::Unlimited => None,
|
||||
@@ -47,32 +64,46 @@ impl CallDeadline {
|
||||
/// 只有尚未耗尽的运行预算可以暂停;重复 next 不能重置或复活预算
|
||||
#[must_use]
|
||||
pub fn pause(&self) -> Option<CallDeadlinePause> {
|
||||
self.state
|
||||
.send_if_modified(|state| {
|
||||
let State::Running(at) = *state else {
|
||||
return false;
|
||||
};
|
||||
let remaining = at.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return false;
|
||||
}
|
||||
*state = State::Paused(remaining);
|
||||
true
|
||||
})
|
||||
.then(|| CallDeadlinePause {
|
||||
deadline: self.clone(),
|
||||
})
|
||||
let mut state = self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let State::Running(at) = *state else {
|
||||
return None;
|
||||
};
|
||||
let remaining = at.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return None;
|
||||
}
|
||||
*state = State::Paused(remaining);
|
||||
// 暂停只延后期限,无需唤醒处理任务;旧计时器到期会重新核对状态
|
||||
// 否则父任务会把自身停表通知误当成业务唤醒,反复请求恢复
|
||||
Some(CallDeadlinePause {
|
||||
deadline: self.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 初始资源流响应已交付,后续期限由资源 owner 持有
|
||||
pub fn clear(&self) {
|
||||
self.state.send_replace(State::Unlimited);
|
||||
*self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = State::Unlimited;
|
||||
self.shared.changed.notify_waiters();
|
||||
}
|
||||
|
||||
pub async fn wait(&self) {
|
||||
let mut state = self.state.subscribe();
|
||||
loop {
|
||||
let current = *state.borrow_and_update();
|
||||
let changed = self.shared.changed.notified();
|
||||
tokio::pin!(changed);
|
||||
changed.as_mut().enable();
|
||||
let current = *self
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let elapsed = async {
|
||||
match current {
|
||||
State::Running(at) => tokio::time::sleep_until(at).await,
|
||||
@@ -81,7 +112,7 @@ impl CallDeadline {
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = state.changed() => {}
|
||||
() = changed => {}
|
||||
() = elapsed => {
|
||||
// 暂停可与旧计时器唤醒并发,返回前重新核对当前预算
|
||||
if self.is_elapsed() {
|
||||
@@ -95,12 +126,16 @@ impl CallDeadline {
|
||||
|
||||
impl Drop for CallDeadlinePause {
|
||||
fn drop(&mut self) {
|
||||
self.deadline.state.send_if_modified(|state| {
|
||||
let State::Paused(remaining) = *state else {
|
||||
return false;
|
||||
};
|
||||
let mut state = self
|
||||
.deadline
|
||||
.shared
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let State::Paused(remaining) = *state {
|
||||
*state = State::Running(Instant::now() + remaining);
|
||||
true
|
||||
});
|
||||
drop(state);
|
||||
self.deadline.shared.changed.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ use super::{
|
||||
lifecycle::{CancellationSource, LifecycleHooks},
|
||||
output::{Output, writer_loop},
|
||||
registry::CallbackRegistry,
|
||||
scheduling::CallSchedule,
|
||||
stream::validate_stream_chunk,
|
||||
};
|
||||
|
||||
@@ -107,12 +108,13 @@ where
|
||||
.config
|
||||
.maximum_calls
|
||||
.checked_add(self.config.maximum_callbacks)
|
||||
.and_then(|capacity| capacity.checked_add(self.config.maximum_calls))
|
||||
.ok_or(SessionError::Configuration)?;
|
||||
let control_reserve = self
|
||||
.config
|
||||
.maximum_calls
|
||||
.checked_add(8)
|
||||
.checked_mul(2)
|
||||
.and_then(|capacity| capacity.checked_add(self.config.maximum_callbacks))
|
||||
.and_then(|capacity| capacity.checked_add(8))
|
||||
.ok_or(SessionError::Configuration)?;
|
||||
let queue_capacity = data_capacity
|
||||
.checked_add(control_reserve)
|
||||
@@ -121,17 +123,15 @@ where
|
||||
let output = Output {
|
||||
sender: outbound,
|
||||
data_slots: Arc::new(Semaphore::new(data_capacity)),
|
||||
failed: tokio::sync::watch::channel(false).0,
|
||||
};
|
||||
let callbacks = Arc::new(CallbackRegistry::new(
|
||||
self.config.maximum_callbacks,
|
||||
self.config.maximum_calls,
|
||||
));
|
||||
let callbacks = Arc::new(CallbackRegistry::new(self.config.maximum_callbacks));
|
||||
let handler: Arc<dyn PluginHandler> = Arc::new(handler);
|
||||
let lifecycle = LifecycleHooks::new(Arc::clone(&handler), control_reserve)?;
|
||||
write_frame(
|
||||
&mut self.writer,
|
||||
&Frame::control(Message::Ready {
|
||||
protocol_version: PROTOCOL_VERSION,
|
||||
protocol_version: self.handshake.protocol_version,
|
||||
incarnation: self.handshake.incarnation.clone(),
|
||||
call_budgets: self.call_budgets,
|
||||
}),
|
||||
@@ -167,6 +167,7 @@ struct ActiveCall {
|
||||
credits: Arc<CreditWindow>,
|
||||
cancellation: CancellationSource,
|
||||
task: JoinHandle<()>,
|
||||
schedule: Arc<CallSchedule>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -243,6 +244,7 @@ impl SessionDriver {
|
||||
let frame = loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = self.output.failed() => return Err(SessionError::Capacity),
|
||||
writer = &mut self.writer => {
|
||||
return writer.map_err(|_| SessionError::Closed)?;
|
||||
}
|
||||
@@ -296,6 +298,16 @@ impl SessionDriver {
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
Message::Resumed { id, epoch } if frame.payload.is_empty() => {
|
||||
if id == 0 || id.is_multiple_of(2) || id > self.last_call || epoch == 0 {
|
||||
return Err(SessionError::Protocol);
|
||||
}
|
||||
// 取消和完成可能先于同一授权抵达,已退休调用不能被重新打开
|
||||
if let Some(active) = self.active.get(&id) {
|
||||
active.schedule.resumed(epoch)?;
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
Message::Cancel { id } if frame.payload.is_empty() => {
|
||||
self.cancel_call(id)?;
|
||||
Ok(false)
|
||||
@@ -367,6 +379,7 @@ impl SessionDriver {
|
||||
let deadline = CallDeadline::new(deadline);
|
||||
let credits = Arc::new(CreditWindow::default());
|
||||
let (cancellation, cancellation_signal) = CancellationSource::new();
|
||||
let schedule = Arc::new(CallSchedule::new(id, self.output.clone(), deadline.clone()));
|
||||
let host = HostClient {
|
||||
parent_id: id,
|
||||
deadline: deadline.clone(),
|
||||
@@ -375,6 +388,7 @@ impl SessionDriver {
|
||||
output: self.output.clone(),
|
||||
maximum_stream_chunk_bytes: self.config.maximum_stream_chunk_bytes,
|
||||
credits: credits.clone(),
|
||||
schedule: schedule.clone(),
|
||||
};
|
||||
let call = PluginCall {
|
||||
method,
|
||||
@@ -392,6 +406,8 @@ impl SessionDriver {
|
||||
let maximum_chunks = self.config.maximum_buffered_stream_chunks;
|
||||
let task_context = context.clone();
|
||||
let lifecycle = self.lifecycle.clone();
|
||||
let task_schedule = schedule.clone();
|
||||
let callbacks = Arc::clone(&self.callbacks);
|
||||
let task = tokio::spawn(async move {
|
||||
let guard = CompletionGuard::new(id, completed);
|
||||
let result = run_call(
|
||||
@@ -404,6 +420,8 @@ impl SessionDriver {
|
||||
deadline,
|
||||
maximum_stream_chunk_bytes,
|
||||
maximum_chunks,
|
||||
task_schedule,
|
||||
callbacks,
|
||||
)
|
||||
.await;
|
||||
guard.finish(if result.is_ok() {
|
||||
@@ -419,6 +437,7 @@ impl SessionDriver {
|
||||
credits,
|
||||
cancellation,
|
||||
task,
|
||||
schedule,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
@@ -478,9 +497,12 @@ async fn run_call(
|
||||
deadline: CallDeadline,
|
||||
maximum_stream_chunk_bytes: usize,
|
||||
maximum_chunks: usize,
|
||||
schedule: Arc<CallSchedule>,
|
||||
callbacks: Arc<CallbackRegistry>,
|
||||
) -> Result<(), SessionError> {
|
||||
let id = context.call_id;
|
||||
let cancellation = call.cancellation.clone();
|
||||
let response = schedule.drive(handler.call(call), || callbacks.pauses_budget(id));
|
||||
let response = tokio::select! {
|
||||
biased;
|
||||
() = cancellation.cancelled() => return Ok(()),
|
||||
@@ -492,7 +514,7 @@ async fn run_call(
|
||||
))?;
|
||||
return Ok(());
|
||||
}
|
||||
response = handler.call(call) => response,
|
||||
response = response => response?,
|
||||
};
|
||||
let mut reply = match response {
|
||||
Ok(reply) => reply,
|
||||
|
||||
@@ -9,6 +9,7 @@ mod error;
|
||||
mod lifecycle;
|
||||
mod output;
|
||||
mod registry;
|
||||
mod scheduling;
|
||||
mod stream;
|
||||
|
||||
pub use call::{CallFuture, CallReply, PluginCall, PluginHandler};
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use tokio::{
|
||||
io::AsyncWrite,
|
||||
sync::{OwnedSemaphorePermit, Semaphore, mpsc},
|
||||
sync::{OwnedSemaphorePermit, Semaphore, mpsc, watch},
|
||||
};
|
||||
|
||||
use crate::Frame;
|
||||
@@ -15,6 +15,7 @@ use super::{CallCancellation, CallDeadline, SessionError};
|
||||
pub(super) struct Output {
|
||||
pub(super) sender: mpsc::Sender<Outbound>,
|
||||
pub(super) data_slots: Arc<Semaphore>,
|
||||
pub(super) failed: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl Clone for Output {
|
||||
@@ -22,6 +23,7 @@ impl Clone for Output {
|
||||
Self {
|
||||
sender: self.sender.clone(),
|
||||
data_slots: Arc::clone(&self.data_slots),
|
||||
failed: self.failed.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +67,16 @@ impl Output {
|
||||
frame,
|
||||
_data_slot: None,
|
||||
})
|
||||
.map_err(|_| SessionError::Capacity)
|
||||
.map_err(|_| {
|
||||
// drop 路径也会提交资源确认,队列耗尽不能静默丢失生命周期消息
|
||||
self.failed.send_replace(true);
|
||||
SessionError::Capacity
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn failed(&self) {
|
||||
let mut failed = self.failed.subscribe();
|
||||
let _ = failed.wait_for(|failed| *failed).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::{Frame, Message, call::middleware::NEXT_METHOD};
|
||||
|
||||
use super::super::frame::EncodedFrame;
|
||||
use super::{
|
||||
CallCancellation, CallDeadline, CallDeadlinePause, SessionError, credit::CreditWindow,
|
||||
output::Output,
|
||||
CallCancellation, CallDeadline, SessionError, credit::CreditWindow, output::Output,
|
||||
scheduling::CallSchedule,
|
||||
};
|
||||
|
||||
/// 宿主回调的成功结果;不实现 `Debug`,避免载荷进入普通诊断
|
||||
@@ -32,6 +32,7 @@ pub struct HostClient {
|
||||
pub(super) output: Output,
|
||||
pub(super) maximum_stream_chunk_bytes: usize,
|
||||
pub(super) credits: Arc<CreditWindow>,
|
||||
pub(super) schedule: Arc<CallSchedule>,
|
||||
}
|
||||
|
||||
impl HostClient {
|
||||
@@ -75,6 +76,16 @@ impl HostClient {
|
||||
};
|
||||
let managed_next = method == NEXT_METHOD;
|
||||
let (id, received) = self.callbacks.begin(self.parent_id, managed_next)?;
|
||||
let mut guard = CallbackGuard {
|
||||
id,
|
||||
parent_id: self.parent_id,
|
||||
callbacks: &self.callbacks,
|
||||
output: &self.output,
|
||||
sent: false,
|
||||
};
|
||||
if !managed_next {
|
||||
self.schedule.charge_wait();
|
||||
}
|
||||
let frame = Frame {
|
||||
message: Message::Callback {
|
||||
id,
|
||||
@@ -84,32 +95,23 @@ impl HostClient {
|
||||
},
|
||||
payload,
|
||||
};
|
||||
let frame = EncodedFrame::new(frame).map_err(|_| {
|
||||
self.callbacks.retire(id);
|
||||
SessionError::Protocol
|
||||
})?;
|
||||
let frame = EncodedFrame::new(frame).map_err(|_| SessionError::Protocol)?;
|
||||
let sent = tokio::select! {
|
||||
biased;
|
||||
() = self.cancellation.cancelled() => Err(SessionError::Cancelled),
|
||||
() = self.deadline.wait() => Err(SessionError::Timeout),
|
||||
result = self.output.send_data(frame) => result,
|
||||
};
|
||||
if let Err(error) = sent {
|
||||
self.callbacks.retire(id);
|
||||
return Err(error);
|
||||
}
|
||||
if managed_next {
|
||||
self.callbacks.pause_next(id, &self.deadline);
|
||||
}
|
||||
sent?;
|
||||
guard.sent = true;
|
||||
self.callbacks.mark_sent(id);
|
||||
drop(ordered);
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = self.cancellation.cancelled() => {
|
||||
self.callbacks.retire(id);
|
||||
Err(SessionError::Cancelled)
|
||||
}
|
||||
() = self.deadline.wait() => {
|
||||
self.callbacks.retire(id);
|
||||
Err(SessionError::Timeout)
|
||||
}
|
||||
result = received => result.map_err(|_| SessionError::Closed)?,
|
||||
@@ -121,7 +123,6 @@ pub(super) struct CallbackRegistry {
|
||||
state: Mutex<CallbackState>,
|
||||
order: tokio::sync::Mutex<()>,
|
||||
maximum: usize,
|
||||
maximum_next: usize,
|
||||
maximum_retired: usize,
|
||||
}
|
||||
|
||||
@@ -135,12 +136,33 @@ struct CallbackState {
|
||||
struct PendingCallback {
|
||||
parent_id: u64,
|
||||
managed_next: bool,
|
||||
pause: Option<CallDeadlinePause>,
|
||||
result: oneshot::Sender<Result<HostReply, SessionError>>,
|
||||
sent: bool,
|
||||
result: Option<oneshot::Sender<Result<HostReply, SessionError>>>,
|
||||
}
|
||||
|
||||
struct CallbackGuard<'a> {
|
||||
id: u64,
|
||||
parent_id: u64,
|
||||
callbacks: &'a CallbackRegistry,
|
||||
output: &'a Output,
|
||||
sent: bool,
|
||||
}
|
||||
|
||||
impl Drop for CallbackGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
if self.callbacks.retire(self.id) && self.sent {
|
||||
let _ = self
|
||||
.output
|
||||
.send_control(Frame::control(Message::CallbackConsumed {
|
||||
id: self.id,
|
||||
parent_id: self.parent_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CallbackRegistry {
|
||||
pub(super) fn new(maximum: usize, maximum_next: usize) -> Self {
|
||||
pub(super) fn new(maximum: usize) -> Self {
|
||||
Self {
|
||||
state: Mutex::new(CallbackState {
|
||||
next_id: 2,
|
||||
@@ -150,8 +172,7 @@ impl CallbackRegistry {
|
||||
}),
|
||||
order: tokio::sync::Mutex::new(()),
|
||||
maximum,
|
||||
maximum_next,
|
||||
maximum_retired: (maximum + maximum_next).saturating_mul(2),
|
||||
maximum_retired: maximum.saturating_mul(2),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,18 +185,7 @@ impl CallbackRegistry {
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let maximum = if managed_next {
|
||||
self.maximum_next
|
||||
} else {
|
||||
self.maximum
|
||||
};
|
||||
if state
|
||||
.pending
|
||||
.values()
|
||||
.filter(|pending| pending.managed_next == managed_next)
|
||||
.count()
|
||||
>= maximum
|
||||
{
|
||||
if state.pending.len() >= self.maximum {
|
||||
return Err(SessionError::Capacity);
|
||||
}
|
||||
let id = state.next_id;
|
||||
@@ -186,35 +196,54 @@ impl CallbackRegistry {
|
||||
PendingCallback {
|
||||
parent_id,
|
||||
managed_next,
|
||||
result,
|
||||
pause: None,
|
||||
sent: false,
|
||||
result: Some(result),
|
||||
},
|
||||
);
|
||||
Ok((id, received))
|
||||
}
|
||||
|
||||
fn pause_next(&self, id: u64, deadline: &CallDeadline) {
|
||||
fn mark_sent(&self, id: u64) {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(pending) = state.pending.get_mut(&id) {
|
||||
pending.pause = deadline.pause();
|
||||
if let Some(callback) = state.pending.get_mut(&id) {
|
||||
callback.sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pauses_budget(&self, parent_id: u64) -> bool {
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut has_next = false;
|
||||
for callback in state
|
||||
.pending
|
||||
.values()
|
||||
.filter(|call| call.parent_id == parent_id)
|
||||
{
|
||||
if !callback.managed_next || !callback.sent {
|
||||
return false;
|
||||
}
|
||||
has_next = true;
|
||||
}
|
||||
has_next
|
||||
}
|
||||
|
||||
pub(super) fn resolve(&self, frame: Frame) -> Result<(), SessionError> {
|
||||
let id = match &frame.message {
|
||||
Message::Result { id, .. } | Message::Error { id, .. } => *id,
|
||||
_ => return Err(SessionError::Protocol),
|
||||
};
|
||||
let pending = {
|
||||
let sender = {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(pending) = state.pending.remove(&id) {
|
||||
Some(pending)
|
||||
if let Some(pending) = state.pending.get_mut(&id) {
|
||||
Some(pending.result.take().ok_or(SessionError::Protocol)?)
|
||||
} else if state.retired.remove(&id) {
|
||||
state.retired_order.retain(|retired| *retired != id);
|
||||
None
|
||||
@@ -222,7 +251,7 @@ impl CallbackRegistry {
|
||||
return Err(SessionError::Protocol);
|
||||
}
|
||||
};
|
||||
let Some(mut pending) = pending else {
|
||||
let Some(sender) = sender else {
|
||||
return Ok(());
|
||||
};
|
||||
let result = match frame.message {
|
||||
@@ -235,20 +264,22 @@ impl CallbackRegistry {
|
||||
}
|
||||
_ => return Err(SessionError::Protocol),
|
||||
};
|
||||
// 收到宿主结果就恢复预算,不依赖插件再次轮询 next future
|
||||
pending.pause.take();
|
||||
let _ = pending.result.send(result);
|
||||
let _ = sender.send(result);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retire(&self, id: u64) {
|
||||
fn retire(&self, id: u64) -> bool {
|
||||
let mut state = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if state.pending.remove(&id).is_some() {
|
||||
retire_callback(&mut state, id, self.maximum_retired);
|
||||
if let Some(pending) = state.pending.remove(&id) {
|
||||
if pending.result.is_some() {
|
||||
retire_callback(&mut state, id, self.maximum_retired);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn finish_parent(&self, parent_id: u64) {
|
||||
@@ -261,7 +292,9 @@ impl CallbackRegistry {
|
||||
let mut finished = Vec::new();
|
||||
for (id, pending) in std::mem::take(&mut state.pending) {
|
||||
if pending.parent_id == parent_id {
|
||||
retire_callback(&mut state, id, self.maximum_retired);
|
||||
if pending.result.is_some() {
|
||||
retire_callback(&mut state, id, self.maximum_retired);
|
||||
}
|
||||
finished.push(pending);
|
||||
} else {
|
||||
remaining.insert(id, pending);
|
||||
@@ -271,7 +304,9 @@ impl CallbackRegistry {
|
||||
finished
|
||||
};
|
||||
for pending in pending {
|
||||
let _ = pending.result.send(Err(SessionError::Cancelled));
|
||||
if let Some(result) = pending.result {
|
||||
let _ = result.send(Err(SessionError::Cancelled));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +319,9 @@ impl CallbackRegistry {
|
||||
std::mem::take(&mut state.pending)
|
||||
};
|
||||
for pending in pending.into_values() {
|
||||
let _ = pending.result.send(Err(SessionError::Closed));
|
||||
if let Some(result) = pending.result {
|
||||
let _ = result.send(Err(SessionError::Closed));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! 父处理任务的协作式挂起、唤醒与宿主恢复授权
|
||||
|
||||
use std::{
|
||||
future::{Future, poll_fn},
|
||||
sync::{
|
||||
Arc, Mutex, MutexGuard,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
task::{Context, Poll, Wake, Waker},
|
||||
};
|
||||
|
||||
use crate::{Frame, Message};
|
||||
|
||||
use super::{CallDeadline, CallDeadlinePause, SessionError, output::Output};
|
||||
|
||||
/// 这里只保存协议镜像;执行名额与准入决策始终由宿主持有
|
||||
pub(super) struct CallSchedule {
|
||||
id: u64,
|
||||
output: Output,
|
||||
deadline: CallDeadline,
|
||||
state: Mutex<State>,
|
||||
ready: AtomicBool,
|
||||
waker: Mutex<Option<Waker>>,
|
||||
}
|
||||
|
||||
struct State {
|
||||
epoch: u64,
|
||||
phase: Phase,
|
||||
pause: Option<CallDeadlinePause>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
Running,
|
||||
Suspended,
|
||||
Resuming,
|
||||
}
|
||||
|
||||
impl CallSchedule {
|
||||
pub(super) fn new(id: u64, output: Output, deadline: CallDeadline) -> Self {
|
||||
Self {
|
||||
id,
|
||||
output,
|
||||
deadline,
|
||||
state: Mutex::new(State {
|
||||
epoch: 0,
|
||||
phase: Phase::Running,
|
||||
pause: None,
|
||||
}),
|
||||
ready: AtomicBool::new(false),
|
||||
waker: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn state(&self) -> MutexGuard<'_, State> {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub(super) fn resumed(&self, epoch: u64) -> Result<(), SessionError> {
|
||||
let waker = {
|
||||
let mut state = self.state();
|
||||
if state.phase != Phase::Resuming || state.epoch != epoch {
|
||||
return Err(SessionError::Protocol);
|
||||
}
|
||||
state.phase = Phase::Running;
|
||||
state.pause.take();
|
||||
self.waker
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.take()
|
||||
};
|
||||
if let Some(waker) = waker {
|
||||
waker.wake();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 后台工作发起应计时的回调时,不能借父任务等待 next 停表
|
||||
pub(super) fn charge_wait(&self) {
|
||||
self.state().pause.take();
|
||||
}
|
||||
|
||||
pub(super) async fn drive<F: Future>(
|
||||
self: &Arc<Self>,
|
||||
future: F,
|
||||
pauses_budget: impl Fn() -> bool,
|
||||
) -> Result<F::Output, SessionError> {
|
||||
let mut future = std::pin::pin!(future);
|
||||
let waker = Waker::from(Arc::clone(self));
|
||||
poll_fn(|cx| {
|
||||
{
|
||||
let mut state = self.state();
|
||||
*self
|
||||
.waker
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(cx.waker().clone());
|
||||
match state.phase {
|
||||
Phase::Resuming => return Poll::Pending,
|
||||
Phase::Suspended => {
|
||||
if self.ready.load(Ordering::Acquire) {
|
||||
self.output.send_control(Frame::control(Message::Resume {
|
||||
id: self.id,
|
||||
epoch: state.epoch,
|
||||
}))?;
|
||||
state.phase = Phase::Resuming;
|
||||
}
|
||||
return Poll::Pending;
|
||||
}
|
||||
Phase::Running => self.ready.store(false, Ordering::Release),
|
||||
}
|
||||
}
|
||||
let mut task_context = Context::from_waker(&waker);
|
||||
if let Poll::Ready(result) = future.as_mut().poll(&mut task_context) {
|
||||
return Poll::Ready(Ok(result));
|
||||
}
|
||||
let mut state = self.state();
|
||||
state.epoch = state.epoch.checked_add(1).ok_or(SessionError::Capacity)?;
|
||||
// 与回调共享单一 FIFO,宿主看到挂起前一定已看到本次轮询提交的回调
|
||||
self.output.send_control(Frame::control(Message::Suspend {
|
||||
id: self.id,
|
||||
epoch: state.epoch,
|
||||
}))?;
|
||||
state.phase = Phase::Suspended;
|
||||
if pauses_budget() {
|
||||
state.pause = self.deadline.pause();
|
||||
}
|
||||
if self.ready.load(Ordering::Acquire) {
|
||||
// future 可以在 poll 返回 Pending 前唤醒自己,这次唤醒也必须经过授权
|
||||
cx.waker().wake_by_ref();
|
||||
}
|
||||
Poll::Pending
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl Wake for CallSchedule {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.wake_by_ref();
|
||||
}
|
||||
|
||||
fn wake_by_ref(self: &Arc<Self>) {
|
||||
// 期限恢复会同步唤醒回调等待者;唤醒路径不能再次取得调度状态锁
|
||||
self.ready.store(true, Ordering::Release);
|
||||
let waker = self
|
||||
.waker
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
if let Some(waker) = waker {
|
||||
waker.wake();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{CallBudgets, CallContext, Handshake, PluginFault};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 6;
|
||||
pub const PROTOCOL_VERSION: u32 = 7;
|
||||
|
||||
/// 元数据与二进制载荷分开,流分块不经过 JSON/base64
|
||||
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -42,6 +42,26 @@ pub enum Message {
|
||||
#[serde(skip)]
|
||||
params: serde_json::Value,
|
||||
},
|
||||
/// 父处理任务已交还执行权,epoch 在同一调用内严格递增
|
||||
Suspend {
|
||||
id: u64,
|
||||
epoch: u64,
|
||||
},
|
||||
/// 已挂起任务被唤醒,请求重新取得执行名额
|
||||
Resume {
|
||||
id: u64,
|
||||
epoch: u64,
|
||||
},
|
||||
/// 宿主已取得执行名额,插件才可再次驱动父处理任务
|
||||
Resumed {
|
||||
id: u64,
|
||||
epoch: u64,
|
||||
},
|
||||
/// 回调 future 已消费结果或被释放,宿主可回收对应驻留额度
|
||||
CallbackConsumed {
|
||||
id: u64,
|
||||
parent_id: u64,
|
||||
},
|
||||
Cancel {
|
||||
id: u64,
|
||||
},
|
||||
@@ -102,6 +122,10 @@ impl std::fmt::Debug for Message {
|
||||
Self::Result { id, .. } => ("Result", Some(id)),
|
||||
Self::Error { id, .. } => ("Error", Some(id)),
|
||||
Self::Callback { id, .. } => ("Callback", Some(id)),
|
||||
Self::Suspend { id, .. } => ("Suspend", Some(id)),
|
||||
Self::Resume { id, .. } => ("Resume", Some(id)),
|
||||
Self::Resumed { id, .. } => ("Resumed", Some(id)),
|
||||
Self::CallbackConsumed { id, .. } => ("CallbackConsumed", Some(id)),
|
||||
Self::Cancel { id } => ("Cancel", Some(id)),
|
||||
Self::Cancelled { id } => ("Cancelled", Some(id)),
|
||||
Self::Stream { id, .. } => ("Stream", Some(id)),
|
||||
|
||||
@@ -5,7 +5,7 @@ use gateway_plugin_sdk::client::{CallFuture, CallReply, PluginCall, PluginHandle
|
||||
use gateway_plugin_sdk::{ErrorCode, Frame, Message};
|
||||
use gateway_plugin_sdk::{
|
||||
call::{
|
||||
host::HttpRequest,
|
||||
host::{AccountHttpRequest, HttpRequest},
|
||||
upstream_adapter::{UpstreamHttpRequest, UpstreamWebSocketRequest, WebSocketMessageKind},
|
||||
},
|
||||
client::UpstreamWebSocketUpgrade,
|
||||
@@ -91,6 +91,21 @@ impl PluginHandler for NetworkHandler {
|
||||
call.payload,
|
||||
)
|
||||
.await?
|
||||
} else if call.method == "account" {
|
||||
call.host
|
||||
.account_http(
|
||||
AccountHttpRequest {
|
||||
account_id: "acct_one".into(),
|
||||
credential_revision: 7,
|
||||
request: HttpRequest {
|
||||
method: "POST".into(),
|
||||
url: "https://example.test/responses".into(),
|
||||
headers: vec![],
|
||||
},
|
||||
},
|
||||
call.payload,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
call.host
|
||||
.upstream_http(
|
||||
@@ -157,7 +172,7 @@ async fn reply_callback(
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
|
||||
for method in ["http", "upstream"] {
|
||||
for method in ["http", "account", "upstream"] {
|
||||
let (mut host, task) = start_session(NetworkHandler).await;
|
||||
send_call(
|
||||
&mut host,
|
||||
@@ -167,19 +182,28 @@ async fn http_response_objects_share_pull_semantics_and_retire_eof_handles() {
|
||||
b"request".to_vec(),
|
||||
)
|
||||
.await;
|
||||
let prefix = if method == "http" {
|
||||
let prefix = if method != "upstream" {
|
||||
"host.http"
|
||||
} else {
|
||||
"host.upstream.http"
|
||||
};
|
||||
let open_method = if method == "account" {
|
||||
gateway_plugin_sdk::call::host::ACCOUNT_HTTP.to_owned()
|
||||
} else {
|
||||
format!("{prefix}.do_stream")
|
||||
};
|
||||
let frame = reply_callback(
|
||||
&mut host,
|
||||
&format!("{prefix}.do_stream"),
|
||||
&open_method,
|
||||
json!({"status":200,"http_version":"HTTP/1.1","trailers":[],"headers":[["x-bytes",[255]],["x-bytes",b"other".to_vec()]],"stream":"private-handle"}),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(frame.payload, b"request");
|
||||
if method == "account" {
|
||||
assert!(matches!(frame.message, Message::Callback { params, .. }
|
||||
if params["account_id"] == "acct_one" && params["credential_revision"] == 7));
|
||||
}
|
||||
let frame = reply_callback(
|
||||
&mut host,
|
||||
&format!("{prefix}.stream_read"),
|
||||
|
||||
@@ -519,10 +519,25 @@ async fn send_call(
|
||||
}
|
||||
|
||||
async fn receive(host: &mut HostPeer) -> Frame {
|
||||
tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
|
||||
.await
|
||||
.expect("plugin response timed out")
|
||||
.expect("plugin response frame must be valid")
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
let frame = read_frame(&mut host.reader).await?;
|
||||
match frame.message {
|
||||
Message::Suspend { .. } | Message::CallbackConsumed { .. } => {}
|
||||
Message::Resume { id, epoch } => {
|
||||
write_frame(
|
||||
&mut host.writer,
|
||||
&Frame::control(Message::Resumed { id, epoch }),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
_ => return Ok::<_, gateway_plugin_sdk::FrameError>(frame),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("plugin response timed out")
|
||||
.expect("plugin response frame must be valid")
|
||||
}
|
||||
|
||||
fn unwrap_result(frame: Frame, expected_id: u64) -> (Value, Vec<u8>) {
|
||||
|
||||
@@ -111,7 +111,7 @@ async fn verify_cancelled_read(method: &str) {
|
||||
.await;
|
||||
let first = callback_id(&mut host, read).await;
|
||||
signals.cancel.notify_one();
|
||||
signals.dropped.notified().await;
|
||||
session::wait_suspended(&mut host, &signals.dropped).await;
|
||||
// 已取消的读取随后成功,下一次 read 应恢复同一读取结果
|
||||
reply(&mut host, first, data.clone(), b"first").await;
|
||||
signals.resume.notify_one();
|
||||
@@ -264,7 +264,7 @@ async fn middleware_http_and_websocket_reads_resume_the_pending_chunk() {
|
||||
};
|
||||
let id = callback_id(&mut host, method).await;
|
||||
signals.cancel.notify_one();
|
||||
signals.dropped.notified().await;
|
||||
session::wait_suspended(&mut host, &signals.dropped).await;
|
||||
reply(&mut host, id, json!({"eof":false}), b"first").await;
|
||||
signals.resume.notify_one();
|
||||
let frame = session::receive(&mut host).await;
|
||||
@@ -296,7 +296,7 @@ async fn model_frame_survives_cancellation_while_fetching_its_facts() {
|
||||
.await;
|
||||
let id = callback_id(&mut host, BODY_FACTS_METHOD).await;
|
||||
signals.cancel.notify_one();
|
||||
signals.dropped.notified().await;
|
||||
session::wait_suspended(&mut host, &signals.dropped).await;
|
||||
reply(&mut host, id, json!({"present":false}), b"").await;
|
||||
signals.resume.notify_one();
|
||||
let frame = session::receive(&mut host).await;
|
||||
@@ -314,7 +314,7 @@ async fn http_forwarding_retains_a_pending_read_under_output_backpressure() {
|
||||
)
|
||||
.await;
|
||||
signals.cancel.notify_one();
|
||||
signals.dropped.notified().await;
|
||||
session::wait_suspended(&mut host, &signals.dropped).await;
|
||||
reply(&mut host, id, json!({"eof":false}), b"first").await;
|
||||
signals.resume.notify_one();
|
||||
write_frame(
|
||||
@@ -359,7 +359,7 @@ async fn pending_http_read_is_split_when_credit_arrives_after_the_callback() {
|
||||
)
|
||||
.await;
|
||||
signals.cancel.notify_one();
|
||||
signals.dropped.notified().await;
|
||||
session::wait_suspended(&mut host, &signals.dropped).await;
|
||||
let expected = vec![b'a'; 8192];
|
||||
reply(&mut host, id, json!({"eof":false}), &expected).await;
|
||||
signals.resume.notify_one();
|
||||
@@ -417,7 +417,7 @@ async fn websocket_transfer_rejects_a_payload_with_a_pending_read() {
|
||||
let (mut host, task, signals) = start_middleware(Action::WebSocketTransfer).await;
|
||||
let _id = callback_id(&mut host, BODY_READ_METHOD).await;
|
||||
signals.cancel.notify_one();
|
||||
signals.dropped.notified().await;
|
||||
session::wait_suspended(&mut host, &signals.dropped).await;
|
||||
signals.resume.notify_one();
|
||||
assert!(matches!(session::receive(&mut host).await.message,
|
||||
Message::Error { id: 1, error } if error.code == ErrorCode::InvalidInput));
|
||||
|
||||
@@ -268,12 +268,29 @@ async fn completed_call_does_not_discard_a_partially_read_next_frame() {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
// 单字节缓冲保证前缀已经进入读取 future,而不是仍滞留在传输队列
|
||||
let (mut host, task) = start_session_with_capacity(TestHandler::default(), 1).await;
|
||||
send_call(&mut host, 1, "slow", json!("first"), Vec::new()).await;
|
||||
host.writer.write_all(&encoded[..split]).await.unwrap();
|
||||
send_call(&mut host, 1, "delayed_stream", json!({}), Vec::new()).await;
|
||||
send_control(
|
||||
&mut host,
|
||||
Message::Credit {
|
||||
id: 1,
|
||||
bytes: 1_024,
|
||||
frames: 1,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
receive(&mut host).await.message,
|
||||
Message::Result { id: 1, .. }
|
||||
));
|
||||
host.writer.write_all(&encoded[..split]).await.unwrap();
|
||||
assert!(matches!(
|
||||
receive(&mut host).await.message,
|
||||
Message::Stream { id: 1, sequence: 0 }
|
||||
));
|
||||
assert!(matches!(
|
||||
receive(&mut host).await.message,
|
||||
Message::End { id: 1, error: None }
|
||||
));
|
||||
host.writer.write_all(&encoded[split..]).await.unwrap();
|
||||
assert_eq!(
|
||||
receive(&mut host).await,
|
||||
|
||||
@@ -5,6 +5,7 @@ mod deadline;
|
||||
mod driver;
|
||||
mod lifecycle;
|
||||
mod registry;
|
||||
mod scheduling;
|
||||
|
||||
use std::{
|
||||
future::pending,
|
||||
@@ -153,6 +154,14 @@ impl PluginHandler for TestHandler {
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
Ok(CallReply::unary(call.params, call.payload))
|
||||
}
|
||||
"delayed_stream" => {
|
||||
let (sender, stream) = ResponseStream::channel(NonZeroUsize::new(1).unwrap());
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(80)).await;
|
||||
sender.send(b"done".to_vec()).await.unwrap();
|
||||
});
|
||||
Ok(CallReply::stream(json!({}), Vec::new(), stream))
|
||||
}
|
||||
"callback" | "next_callback" => {
|
||||
let method = if call.method == "next_callback" {
|
||||
NEXT_METHOD
|
||||
@@ -466,10 +475,49 @@ async fn send_control(host: &mut HostPeer, message: Message) {
|
||||
}
|
||||
|
||||
pub(super) async fn receive(host: &mut HostPeer) -> Frame {
|
||||
tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
|
||||
.await
|
||||
.expect("plugin response timed out")
|
||||
.expect("plugin response frame must be valid")
|
||||
tokio::time::timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
let frame = read_frame(&mut host.reader).await?;
|
||||
match frame.message {
|
||||
Message::Suspend { .. } | Message::CallbackConsumed { .. } => {}
|
||||
Message::Resume { id, epoch } => {
|
||||
send_control(host, Message::Resumed { id, epoch }).await;
|
||||
}
|
||||
_ => return Ok::<_, gateway_plugin_sdk::FrameError>(frame),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("plugin response timed out")
|
||||
.expect("plugin response frame must be valid")
|
||||
}
|
||||
|
||||
// 等待测试事件时仍推进恢复授权,且只在完整挂起帧边界返回,避免取消半帧读取
|
||||
pub(super) async fn wait_suspended(host: &mut HostPeer, event: &tokio::sync::Notify) {
|
||||
let event = event.notified();
|
||||
tokio::pin!(event);
|
||||
loop {
|
||||
let frame = tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
match frame.message {
|
||||
Message::Suspend { .. } => {
|
||||
if std::future::poll_fn(|cx| {
|
||||
std::task::Poll::Ready(event.as_mut().poll(cx).is_ready())
|
||||
})
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
Message::Resume { id, epoch } => {
|
||||
send_control(host, Message::Resumed { id, epoch }).await
|
||||
}
|
||||
Message::CallbackConsumed { .. } => {}
|
||||
message => panic!("unexpected frame while waiting for suspension: {message:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn shutdown(host: &mut HostPeer, task: JoinHandle<Result<(), SessionError>>) {
|
||||
|
||||
@@ -297,57 +297,3 @@ async fn typed_key_and_quota_calls_keep_payloads_and_do_not_retry_failures() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_next_does_not_consume_the_bounded_ordinary_callback_budget() {
|
||||
let (mut host, task) = start_configured_session(
|
||||
TestHandler::default(),
|
||||
MAXIMUM_STREAM_CHUNK_BYTES * 2,
|
||||
SessionConfig {
|
||||
maximum_calls: 4,
|
||||
maximum_callbacks: 1,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
send_call(&mut host, 1, "next_callback", json!({}), Vec::new()).await;
|
||||
let Message::Callback {
|
||||
id: next,
|
||||
parent_id: 1,
|
||||
method,
|
||||
..
|
||||
} = receive(&mut host).await.message
|
||||
else {
|
||||
panic!("expected next callback")
|
||||
};
|
||||
assert_eq!(method, NEXT_METHOD);
|
||||
send_call(&mut host, 3, "callback", json!({}), Vec::new()).await;
|
||||
let Message::Callback {
|
||||
id: ordinary,
|
||||
parent_id: 3,
|
||||
method,
|
||||
..
|
||||
} = receive(&mut host).await.message
|
||||
else {
|
||||
panic!("next must not occupy ordinary callback capacity")
|
||||
};
|
||||
assert_eq!(method, "host.log");
|
||||
send_call(&mut host, 5, "callback", json!({}), Vec::new()).await;
|
||||
assert!(
|
||||
matches!(receive(&mut host).await.message, Message::Error { id: 5, error } if error.code == ErrorCode::Capacity)
|
||||
);
|
||||
for (callback, parent) in [(ordinary, 3), (next, 1)] {
|
||||
send_control(
|
||||
&mut host,
|
||||
Message::Result {
|
||||
id: callback,
|
||||
result: json!({}),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(receive(&mut host).await.message, Message::Result { id, .. } if id == parent)
|
||||
);
|
||||
}
|
||||
shutdown(&mut host, task).await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! 协作调度的部分完成、唤醒竞争、预算与回调回收合同测试
|
||||
|
||||
use std::{
|
||||
future::{Future, poll_fn},
|
||||
task::Poll,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct ScheduledHandler {
|
||||
progressed: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl PluginHandler for ScheduledHandler {
|
||||
fn call(&self, call: PluginCall) -> CallFuture<'_> {
|
||||
Box::pin(async move {
|
||||
match call.method.as_str() {
|
||||
"parallel" | "mixed" => {
|
||||
let first_method = if call.method == "mixed" {
|
||||
NEXT_METHOD
|
||||
} else {
|
||||
"host.first"
|
||||
};
|
||||
let first = async {
|
||||
let reply = call.host.call(first_method, json!({}), Vec::new()).await;
|
||||
self.progressed.fetch_add(1, Ordering::SeqCst);
|
||||
reply
|
||||
};
|
||||
let second = async {
|
||||
let reply = call.host.call("host.second", json!({}), Vec::new()).await;
|
||||
self.progressed.fetch_add(1, Ordering::SeqCst);
|
||||
reply
|
||||
};
|
||||
let (first, second) = tokio::join!(first, second);
|
||||
first.map_err(SessionError::into_plugin_fault)?;
|
||||
second.map_err(SessionError::into_plugin_fault)?;
|
||||
}
|
||||
"yield" => {
|
||||
tokio::task::yield_now().await;
|
||||
self.progressed.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
"drop_callback" => {
|
||||
let mut callback =
|
||||
Box::pin(call.host.call("host.detached", json!({}), Vec::new()));
|
||||
poll_fn(|cx| {
|
||||
assert!(callback.as_mut().poll(cx).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
drop(callback);
|
||||
}
|
||||
"saturate" => {
|
||||
let mut callbacks = [
|
||||
Box::pin(call.host.call(NEXT_METHOD, json!({}), Vec::new())),
|
||||
Box::pin(call.host.call(NEXT_METHOD, json!({}), Vec::new())),
|
||||
];
|
||||
for callback in &mut callbacks {
|
||||
poll_fn(|cx| {
|
||||
assert!(callback.as_mut().poll(cx).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let refused = call.host.call("host.third", json!({}), Vec::new()).await;
|
||||
assert!(matches!(refused, Err(SessionError::Capacity)));
|
||||
}
|
||||
_ => {
|
||||
return Err(PluginFault::new(
|
||||
ErrorCode::Unsupported,
|
||||
"unknown scheduling test call",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(CallReply::unary(json!({}), Vec::new()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn wire(host: &mut HostPeer) -> Message {
|
||||
let frame = tokio::time::timeout(Duration::from_secs(1), read_frame(&mut host.reader))
|
||||
.await
|
||||
.expect("expected protocol progress")
|
||||
.unwrap();
|
||||
assert!(frame.payload.is_empty());
|
||||
frame.message
|
||||
}
|
||||
|
||||
async fn callback(host: &mut HostPeer, method: &str) -> u64 {
|
||||
let Message::Callback {
|
||||
id,
|
||||
parent_id: 1,
|
||||
method: actual,
|
||||
..
|
||||
} = wire(host).await
|
||||
else {
|
||||
panic!("expected callback before suspension");
|
||||
};
|
||||
assert_eq!(actual, method);
|
||||
id
|
||||
}
|
||||
|
||||
async fn callback_result(host: &mut HostPeer, id: u64) {
|
||||
send_control(
|
||||
host,
|
||||
Message::Result {
|
||||
id,
|
||||
result: json!({}),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn partial_callback_results_require_a_new_execution_grant_before_processing() {
|
||||
let progressed = Arc::new(AtomicUsize::new(0));
|
||||
let (mut host, task) = start_session(ScheduledHandler {
|
||||
progressed: Arc::clone(&progressed),
|
||||
})
|
||||
.await;
|
||||
send_call(&mut host, 1, "parallel", json!({}), Vec::new()).await;
|
||||
let first = callback(&mut host, "host.first").await;
|
||||
let second = callback(&mut host, "host.second").await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Suspend { id: 1, epoch: 1 }
|
||||
));
|
||||
|
||||
callback_result(&mut host, first).await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Resume { id: 1, epoch: 1 }
|
||||
));
|
||||
assert_eq!(progressed.load(Ordering::SeqCst), 0);
|
||||
send_control(&mut host, Message::Resumed { id: 1, epoch: 1 }).await;
|
||||
assert!(
|
||||
matches!(wire(&mut host).await, Message::CallbackConsumed { id, parent_id: 1 } if id == first)
|
||||
);
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Suspend { id: 1, epoch: 2 }
|
||||
));
|
||||
assert_eq!(progressed.load(Ordering::SeqCst), 1);
|
||||
|
||||
callback_result(&mut host, second).await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Resume { id: 1, epoch: 2 }
|
||||
));
|
||||
assert_eq!(progressed.load(Ordering::SeqCst), 1);
|
||||
send_control(&mut host, Message::Resumed { id: 1, epoch: 2 }).await;
|
||||
assert!(
|
||||
matches!(wire(&mut host).await, Message::CallbackConsumed { id, parent_id: 1 } if id == second)
|
||||
);
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Result { id: 1, .. }
|
||||
));
|
||||
assert_eq!(progressed.load(Ordering::SeqCst), 2);
|
||||
shutdown(&mut host, task).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wake_during_poll_is_preserved_but_cannot_skip_host_authorization() {
|
||||
let progressed = Arc::new(AtomicUsize::new(0));
|
||||
let (mut host, task) = start_session(ScheduledHandler {
|
||||
progressed: Arc::clone(&progressed),
|
||||
})
|
||||
.await;
|
||||
send_call(&mut host, 1, "yield", json!({}), Vec::new()).await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Suspend { id: 1, epoch: 1 }
|
||||
));
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Resume { id: 1, epoch: 1 }
|
||||
));
|
||||
assert_eq!(progressed.load(Ordering::SeqCst), 0);
|
||||
send_control(&mut host, Message::Resumed { id: 1, epoch: 1 }).await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Result { id: 1, .. }
|
||||
));
|
||||
assert_eq!(progressed.load(Ordering::SeqCst), 1);
|
||||
shutdown(&mut host, task).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_during_resume_does_not_allow_late_grants_to_revive_the_call() {
|
||||
let (mut host, task) = start_session(TestHandler::default()).await;
|
||||
send_call(&mut host, 1, "callback", json!({}), Vec::new()).await;
|
||||
let child = callback(&mut host, "host.log").await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Suspend { id: 1, epoch: 1 }
|
||||
));
|
||||
callback_result(&mut host, child).await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Resume { id: 1, epoch: 1 }
|
||||
));
|
||||
send_control(&mut host, Message::Cancel { id: 1 }).await;
|
||||
send_control(&mut host, Message::Resumed { id: 1, epoch: 1 }).await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Cancelled { id: 1 }
|
||||
));
|
||||
send_call(&mut host, 3, "echo", json!("alive"), Vec::new()).await;
|
||||
assert!(
|
||||
matches!(wire(&mut host).await, Message::Result { id: 3, result } if result == json!("alive"))
|
||||
);
|
||||
shutdown(&mut host, task).await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn next_pause_covers_resume_wait_but_cannot_hide_parallel_chargeable_work() {
|
||||
let (mut host, task) = start_session(TestHandler::default()).await;
|
||||
send_call_with_timeout(
|
||||
&mut host,
|
||||
1,
|
||||
"next_callback",
|
||||
json!({}),
|
||||
Vec::new(),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await;
|
||||
let child = callback(&mut host, NEXT_METHOD).await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Suspend { id: 1, epoch: 1 }
|
||||
));
|
||||
tokio::time::advance(Duration::from_secs(2)).await;
|
||||
callback_result(&mut host, child).await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Resume { id: 1, epoch: 1 }
|
||||
));
|
||||
tokio::time::advance(Duration::from_secs(2)).await;
|
||||
send_control(&mut host, Message::Resumed { id: 1, epoch: 1 }).await;
|
||||
assert!(
|
||||
matches!(wire(&mut host).await, Message::CallbackConsumed { id, parent_id: 1 } if id == child)
|
||||
);
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Result { id: 1, .. }
|
||||
));
|
||||
shutdown(&mut host, task).await;
|
||||
|
||||
let (mut host, task) = start_session(ScheduledHandler {
|
||||
progressed: Arc::new(AtomicUsize::new(0)),
|
||||
})
|
||||
.await;
|
||||
send_call_with_timeout(
|
||||
&mut host,
|
||||
1,
|
||||
"mixed",
|
||||
json!({}),
|
||||
Vec::new(),
|
||||
Duration::from_millis(100),
|
||||
)
|
||||
.await;
|
||||
callback(&mut host, NEXT_METHOD).await;
|
||||
callback(&mut host, "host.second").await;
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Suspend { id: 1, epoch: 1 }
|
||||
));
|
||||
tokio::time::advance(Duration::from_millis(101)).await;
|
||||
loop {
|
||||
match wire(&mut host).await {
|
||||
Message::CallbackConsumed { parent_id: 1, .. } => {}
|
||||
Message::Error { id: 1, error } => {
|
||||
assert_eq!(error.code, ErrorCode::Timeout);
|
||||
break;
|
||||
}
|
||||
other => panic!("chargeable work must time out without a grant: {other:?}"),
|
||||
}
|
||||
}
|
||||
shutdown(&mut host, task).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_a_callback_reports_consumption_before_the_parent_result() {
|
||||
let (mut host, task) = start_session(ScheduledHandler {
|
||||
progressed: Arc::new(AtomicUsize::new(0)),
|
||||
})
|
||||
.await;
|
||||
send_call(&mut host, 1, "drop_callback", json!({}), Vec::new()).await;
|
||||
let child = callback(&mut host, "host.detached").await;
|
||||
assert!(
|
||||
matches!(wire(&mut host).await, Message::CallbackConsumed { id, parent_id: 1 } if id == child)
|
||||
);
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Result { id: 1, .. }
|
||||
));
|
||||
callback_result(&mut host, child).await;
|
||||
shutdown(&mut host, task).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn all_callback_methods_share_the_same_bounded_resident_capacity() {
|
||||
let (mut host, task) = start_configured_session(
|
||||
ScheduledHandler {
|
||||
progressed: Arc::new(AtomicUsize::new(0)),
|
||||
},
|
||||
MAXIMUM_STREAM_CHUNK_BYTES * 2,
|
||||
SessionConfig {
|
||||
maximum_calls: 1,
|
||||
maximum_callbacks: 2,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
send_call(&mut host, 1, "saturate", json!({}), Vec::new()).await;
|
||||
let first = callback(&mut host, NEXT_METHOD).await;
|
||||
let second = callback(&mut host, NEXT_METHOD).await;
|
||||
let mut consumed = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let Message::CallbackConsumed { id, parent_id: 1 } = wire(&mut host).await else {
|
||||
panic!("resident callbacks must be reclaimed");
|
||||
};
|
||||
consumed.push(id);
|
||||
}
|
||||
consumed.sort_unstable();
|
||||
assert_eq!(consumed, [first, second]);
|
||||
assert!(matches!(
|
||||
wire(&mut host).await,
|
||||
Message::Result { id: 1, .. }
|
||||
));
|
||||
shutdown(&mut host, task).await;
|
||||
}
|
||||
+12
-2
@@ -183,6 +183,15 @@ flowchart LR
|
||||
Provider 来源与执行事件快照可由插件读取,包含费用明细、上游观测、会话状态和原始 wire;读取快照不转移计量和资源所有权。
|
||||
数据面插件通过受管 HTTP 已发送、无法证明未发送,或进入主动 HTTP/公开服务调用链时,Runtime 把该事实并入 Core 的请求副作用水位,后续不能按
|
||||
Provider 的 `not_sent` 结果透明重放;RPC 返回或取消前同步封存未完成的发送,并禁止已关闭调用再分派 HTTP/公开服务
|
||||
- **身份查询与执行准入分离**:Core 的 `AuthenticatedClient` 持有身份和冻结权限,模型目录与账号授权直接读取这份事实。
|
||||
Runtime 只按父调用缓存身份快照,查询不建立模型执行图;真正发起模型执行时才检查深度、建立执行归属并申请原生资源。
|
||||
认证复用原有认证计划与 principal 映射,通用 `InvocationOrigin` 传递父调用来源、取消、冻结计划和副作用归属,认证不依赖模型执行上下文
|
||||
- **挂起与恢复由共同边界管理**:SDK 在父处理 future 返回 `Pending` 后报告挂起,再次 poll 前等待 Runtime 的执行授权。
|
||||
Runtime 的调用表持有唯一准入状态,挂起保留总在途名额;结果接收独立于任务恢复,回调额度持续保留到消费、丢弃或父调用终结。
|
||||
并行部分完成可以恢复处理,再次等待时重新归还名额;取消统一收尾,晚到结果与恢复授权不复活调用。
|
||||
是否查询事实、是否调用下游插件、等待是否计时分别由能力合同确定,不用来决定执行名额的归还。
|
||||
这是一项协作式协议,不分析插件私有线程、锁和业务依赖;资源不足返回明确过载,宿主不重放或补偿插件操作。
|
||||
协议与容量见 [SDK 通信边界](../backend/crates/gateway-plugin/sdk/README.md#通信边界)
|
||||
- **主动 HTTP 请求复用总路由**:Runtime 通过 Core 的 `http::Dispatcher` 调用 API 已组装的路由,SDK 的 `dispatch_http` 与 `next` 共用正文资源池。
|
||||
子调用持有父子关联、冻结计划和取消信号,跳过祖先发起实例;模型 request 与 attempt 延续该集合。内部管理请求使用宿主签发的插件身份,审计记录插件与调用来源,普通客户端不能用 headers 声明此身份。
|
||||
正文转交延续真实交付寿命,EOF、显式关闭或父级取消释放资源;组合根保活 Dispatcher,Runtime 只保存 Weak,不增加逐接口注册清单
|
||||
@@ -216,8 +225,9 @@ flowchart LR
|
||||
注册在进程启动与握手完成后使用自己的预算;CLI 的执行与结果提交共用一次期限,重试委托链共享匹配实例中最长的预算并受请求剩余期限约束,单次调用仍受自身声明限制。
|
||||
业务预算不延长进程启动与握手、排队、取消确认、关闭和外部请求期限;声明与默认值见 [SDK 调用预算](../backend/crates/gateway-plugin/sdk/README.md#声明调用预算)
|
||||
- **资源跟随调用**:账号、HTTP、模型、状态和日志回调绑定有效父调用,取消随调用传播。HTTP 与模型 request/attempt 中间件在首个流式响应头交付后,由正文 owner 管理取消与终态,不沿用初始处理期限;网络操作与模型执行各自计时。
|
||||
Runtime 与 SDK 的中间件处理预算在宿主 `next` 等待期间暂停;Runtime 同时归还执行名额,返回后取得名额再以剩余处理预算恢复原调用。
|
||||
执行调度由 Runtime 统一负责,准入和恢复等待使用独立期限;初次排队、总在途上下文和普通回调各有上限;`next` 回调按在途上限独立计数。
|
||||
Runtime 与 SDK 以父处理任务的实际挂起和恢复授权管理执行名额,不按回调方法决定是否让出;
|
||||
`next` 仅在父任务挂起且没有其他应计时回调时暂停处理预算,暂停覆盖恢复排队。
|
||||
执行调度由 Runtime 统一负责,准入和恢复等待使用独立期限;初次排队、总在途上下文和驻留回调各有上限,`next` 与普通回调共享驻留额度。
|
||||
流、取消确认与消费方共同持有同一在途租约,执行名额只覆盖首结果前的处理;调用等待与回调有效期共用各端的预算状态,请求总期限独立生效。
|
||||
显式 Key 的身份快照随调用保留,每个模型执行拥有独立子调用图与结算;省略 Key 时继承现有模型父请求。页面 Responses 桥复核精确实例目标和 revision
|
||||
`frontend_authentication` 使用 principal 到 Key 的映射;公开登录回调的一次性票据用于关联流程,不限制插件回调能力。接口合同见 [SDK 回调](../backend/crates/gateway-plugin/sdk/docs/capabilities.md#宿主资源回调)
|
||||
|
||||
Reference in New Issue
Block a user